Refactor: Admin-Login aus allen Plugins entfernt

Duplizierte Auth-Logik aus Notifications, Game Library und Streaming
Plugins komplett entfernt (-251 Zeilen). Alle Plugins nutzen jetzt
die zentrale Auth aus core/auth.ts via isAdmin Prop.

Admin-Buttons (Settings-Zahnrad) erscheinen nur noch wenn global
eingeloggt. Kein separater Login pro Tab mehr noetig.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-09 11:22:07 +01:00
parent b3080fb763
commit f27093b87a
5 changed files with 28 additions and 251 deletions

View file

@ -1,6 +1,7 @@
import type express from 'express'; import type express from 'express';
import type { Plugin, PluginContext } from '../../core/plugin.js'; import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js'; import { sseBroadcast } from '../../core/sse.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
@ -58,34 +59,6 @@ const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166
// ── Admin auth helpers (same system as soundboard) ── // ── Admin auth helpers (same system as soundboard) ──
function readCookie(req: express.Request, name: string): string | undefined {
const raw = req.headers.cookie || '';
const match = raw.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : undefined;
}
function b64url(str: string): string {
return Buffer.from(str).toString('base64url');
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!token || !adminPwd) return false;
const [body, sig] = token.split('.');
if (!body || !sig) return false;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as { iat: number; exp: number };
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
function signAdminToken(adminPwd: string): string {
const payload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 };
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
// ── Data Persistence ── // ── Data Persistence ──
@ -893,37 +866,7 @@ const gameLibraryPlugin: Plugin = {
// Admin endpoints (same auth as soundboard) // Admin endpoints (same auth as soundboard)
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { const requireAdmin = requireAdminFactory(ctx.adminPwd);
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) {
res.status(401).json({ error: 'Nicht eingeloggt' });
return;
}
next();
};
// ── GET /api/game-library/admin/status ──
app.get('/api/game-library/admin/status', (req, res) => {
if (!ctx.adminPwd) { res.json({ admin: false, configured: false }); return; }
const valid = verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'));
res.json({ admin: valid, configured: true });
});
// ── POST /api/game-library/admin/login ──
app.post('/api/game-library/admin/login', (req, res) => {
const password = String(req.body?.password || '');
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
const token = signAdminToken(ctx.adminPwd);
res.setHeader('Set-Cookie', `admin=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
res.json({ ok: true });
});
// ── POST /api/game-library/admin/logout ──
app.post('/api/game-library/admin/logout', (_req, res) => {
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
res.json({ ok: true });
});
// ── GET /api/game-library/admin/profiles ── Alle Profile mit Details // ── GET /api/game-library/admin/profiles ── Alle Profile mit Details
app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => { app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => {

View file

@ -1,8 +1,8 @@
import type express from 'express'; import type express from 'express';
import crypto from 'node:crypto';
import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js'; import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js'; import type { Plugin, PluginContext } from '../../core/plugin.js';
import { getState, setState } from '../../core/persistence.js'; import { getState, setState } from '../../core/persistence.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
const NB = '[Notifications]'; const NB = '[Notifications]';
@ -26,40 +26,6 @@ let _client: Client | null = null;
let _ctx: PluginContext | null = null; let _ctx: PluginContext | null = null;
let _publicUrl = ''; let _publicUrl = '';
// ── Admin Auth (JWT-like with HMAC) ──
type AdminPayload = { iat: number; exp: number };
function readCookie(req: express.Request, name: string): string | undefined {
const header = req.headers.cookie;
if (!header) return undefined;
const match = header.split(';').map(s => s.trim()).find(s => s.startsWith(`${name}=`));
return match?.split('=').slice(1).join('=');
}
function b64url(str: string): string {
return Buffer.from(str).toString('base64url');
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!adminPwd || !token) return false;
const parts = token.split('.');
if (parts.length !== 2) return false;
const [body, sig] = parts;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as AdminPayload;
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
function signAdminToken(adminPwd: string): string {
const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 };
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
// ── Exported notification functions (called by other plugins) ── // ── Exported notification functions (called by other plugins) ──
@ -159,33 +125,7 @@ const notificationsPlugin: Plugin = {
}, },
registerRoutes(app, ctx) { registerRoutes(app, ctx) {
const requireAdmin = (req: express.Request, res: express.Response, next: () => void): void => { const requireAdmin = requireAdminFactory(ctx.adminPwd);
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
next();
};
// Admin status
app.get('/api/notifications/admin/status', (req, res) => {
if (!ctx.adminPwd) { res.json({ admin: false }); return; }
res.json({ admin: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) });
});
// Admin login
app.post('/api/notifications/admin/login', (req, res) => {
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
const { password } = req.body ?? {};
if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
const token = signAdminToken(ctx.adminPwd);
res.setHeader('Set-Cookie', `admin=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 86400}`);
res.json({ ok: true });
});
// Admin logout
app.post('/api/notifications/admin/logout', (_req, res) => {
res.setHeader('Set-Cookie', 'admin=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
res.json({ ok: true });
});
// List available text channels (requires admin) // List available text channels (requires admin)
app.get('/api/notifications/channels', requireAdmin, async (_req, res) => { app.get('/api/notifications/channels', requireAdmin, async (_req, res) => {

View file

@ -89,7 +89,7 @@ function formatDate(iso: string): string {
COMPONENT COMPONENT
*/ */
export default function GameLibraryTab({ data }: { data: any }) { export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) {
// ── State ── // ── State ──
const [profiles, setProfiles] = useState<ProfileSummary[]>([]); const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
@ -111,11 +111,9 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── Admin state ── // ── Admin state ──
const [showAdmin, setShowAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false); const isAdmin = isAdminProp;
const [adminPwd, setAdminPwd] = useState('');
const [adminProfiles, setAdminProfiles] = useState<any[]>([]); const [adminProfiles, setAdminProfiles] = useState<any[]>([]);
const [adminLoading, setAdminLoading] = useState(false); const [adminLoading, setAdminLoading] = useState(false);
const [adminError, setAdminError] = useState('');
// ── SSE data sync ── // ── SSE data sync ──
useEffect(() => { useEffect(() => {
@ -133,42 +131,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
} catch { /* silent */ } } catch { /* silent */ }
}, []); }, []);
// ── Admin: check login status on mount ──
useEffect(() => {
fetch('/api/game-library/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(d.admin === true))
.catch(() => {});
}, []);
// ── Admin: login ──
const adminLogin = useCallback(async () => {
setAdminError('');
try {
const resp = await fetch('/api/game-library/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
} else {
const d = await resp.json();
setAdminError(d.error || 'Fehler');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}, [adminPwd]);
// ── Admin: logout ──
const adminLogout = useCallback(async () => {
await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
setShowAdmin(false);
}, []);
// ── Admin: load profiles ── // ── Admin: load profiles ──
const loadAdminProfiles = useCallback(async () => { const loadAdminProfiles = useCallback(async () => {
@ -552,9 +514,11 @@ export default function GameLibraryTab({ data }: { data: any }) {
</button> </button>
)} )}
<div className="gl-login-bar-spacer" /> <div className="gl-login-bar-spacer" />
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel"> {isAdmin && (
&#x2699;&#xFE0F; <button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
</button> &#x2699;&#xFE0F;
</button>
)}
</div> </div>
{/* ── Profile Chips ── */} {/* ── Profile Chips ── */}
@ -990,29 +954,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>&#x2715;</button> <button className="gl-admin-close" onClick={() => setShowAdmin(false)}>&#x2715;</button>
</div> </div>
{!isAdmin ? (
<div className="gl-admin-login">
<p>Admin-Passwort eingeben:</p>
<div className="gl-admin-login-row">
<input
type="password"
className="gl-dialog-input"
placeholder="Passwort"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
autoFocus
/>
<button className="gl-admin-login-btn" onClick={adminLogin}>Login</button>
</div>
{adminError && <p className="gl-dialog-status error">{adminError}</p>}
</div>
) : (
<div className="gl-admin-content"> <div className="gl-admin-content">
<div className="gl-admin-toolbar"> <div className="gl-admin-toolbar">
<span className="gl-admin-status-text">&#x2705; Eingeloggt als Admin</span> <span className="gl-admin-status-text">&#x2705; Eingeloggt als Admin</span>
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>&#x21bb; Aktualisieren</button> <button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>&#x21bb; Aktualisieren</button>
<button className="gl-admin-logout-btn" onClick={adminLogout}>Logout</button>
</div> </div>
{adminLoading ? ( {adminLoading ? (
@ -1044,7 +989,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -1000,13 +1000,15 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
)} )}
</div> </div>
)} )}
<button {isAdmin && (
className={`admin-btn-icon ${isAdmin ? 'active' : ''}`} <button
onClick={() => setShowAdmin(true)} className="admin-btn-icon active"
title="Admin" onClick={() => setShowAdmin(true)}
> title="Admin"
<span className="material-icons">settings</span> >
</button> <span className="material-icons">settings</span>
</button>
)}
</div> </div>
</header> </header>

View file

@ -56,7 +56,7 @@ const QUALITY_PRESETS = [
// ── Component ── // ── Component ──
export default function StreamingTab({ data }: { data: any }) { export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) {
// ── State ── // ── State ──
const [streams, setStreams] = useState<StreamInfo[]>([]); const [streams, setStreams] = useState<StreamInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
@ -75,9 +75,7 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Admin / Notification Config ── // ── Admin / Notification Config ──
const [showAdmin, setShowAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false); const isAdmin = isAdminProp;
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
const [availableChannels, setAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]); const [availableChannels, setAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]);
const [notifyConfig, setNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]); const [notifyConfig, setNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]);
const [configLoading, setConfigLoading] = useState(false); const [configLoading, setConfigLoading] = useState(false);
@ -138,12 +136,8 @@ export default function StreamingTab({ data }: { data: any }) {
return () => document.removeEventListener('click', handler); return () => document.removeEventListener('click', handler);
}, [openMenu]); }, [openMenu]);
// Check admin status on mount // Load notification bot status on mount
useEffect(() => { useEffect(() => {
fetch('/api/notifications/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(d.admin === true))
.catch(() => {});
fetch('/api/notifications/status') fetch('/api/notifications/status')
.then(r => r.json()) .then(r => r.json())
.then(d => setNotifyStatus(d)) .then(d => setNotifyStatus(d))
@ -610,34 +604,6 @@ export default function StreamingTab({ data }: { data: any }) {
setOpenMenu(null); setOpenMenu(null);
}, [buildStreamLink]); }, [buildStreamLink]);
// ── Admin functions ──
const adminLogin = useCallback(async () => {
setAdminError('');
try {
const resp = await fetch('/api/notifications/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
loadNotifyConfig();
} else {
const d = await resp.json();
setAdminError(d.error || 'Fehler');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}, [adminPwd]);
const adminLogout = useCallback(async () => {
await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
setShowAdmin(false);
}, []);
const loadNotifyConfig = useCallback(async () => { const loadNotifyConfig = useCallback(async () => {
setConfigLoading(true); setConfigLoading(true);
@ -796,9 +762,11 @@ export default function StreamingTab({ data }: { data: any }) {
{starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'} {starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'}
</button> </button>
)} )}
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen"> {isAdmin && (
{'\u2699\uFE0F'} <button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
</button> {'\u2699\uFE0F'}
</button>
)}
</div> </div>
{streams.length === 0 && !isBroadcasting ? ( {streams.length === 0 && !isBroadcasting ? (
@ -912,24 +880,6 @@ export default function StreamingTab({ data }: { data: any }) {
<button className="stream-admin-close" onClick={() => setShowAdmin(false)}>{'\u2715'}</button> <button className="stream-admin-close" onClick={() => setShowAdmin(false)}>{'\u2715'}</button>
</div> </div>
{!isAdmin ? (
<div className="stream-admin-login">
<p>Admin-Passwort eingeben:</p>
<div className="stream-admin-login-row">
<input
type="password"
className="stream-input"
placeholder="Passwort"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
autoFocus
/>
<button className="stream-btn" onClick={adminLogin}>Login</button>
</div>
{adminError && <p className="stream-admin-error">{adminError}</p>}
</div>
) : (
<div className="stream-admin-content"> <div className="stream-admin-content">
<div className="stream-admin-toolbar"> <div className="stream-admin-toolbar">
<span className="stream-admin-status"> <span className="stream-admin-status">
@ -937,7 +887,6 @@ export default function StreamingTab({ data }: { data: any }) {
? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></> ? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></>
: <>{'\u26A0\uFE0F'} Bot offline <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>} : <>{'\u26A0\uFE0F'} Bot offline <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>}
</span> </span>
<button className="stream-admin-logout" onClick={adminLogout}>Logout</button>
</div> </div>
{configLoading ? ( {configLoading ? (
@ -993,7 +942,6 @@ export default function StreamingTab({ data }: { data: any }) {
</> </>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
)} )}