From 99d69f30baf82c3ca4d450043af82b1f0f0b5e1e Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 10 Mar 2026 20:41:16 +0100 Subject: [PATCH] feat: Discord OAuth Login + User Settings GUI - Neues unified Login-Modal (Discord, Steam, Admin) ersetzt alten Admin-Login - Discord OAuth2 Backend (server/src/core/discord-auth.ts) - User Settings Panel: Entrance/Exit Sounds per Web-GUI konfigurierbar - API-Endpoints: /api/soundboard/user/{sounds,entrance,exit} - Session-Management via HMAC-signierte Cookies (hub_session) - Steam-Button als Platzhalter (bald verfuegbar) - Backward-kompatibel mit bestehendem Admin-Cookie Benoetigte neue Env-Vars: DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET Discord Redirect URI: PUBLIC_URL/api/auth/discord/callback Co-Authored-By: Claude Opus 4.6 --- server/src/core/discord-auth.ts | 247 ++++++++++ server/src/index.ts | 4 + server/src/plugins/soundboard/index.ts | 96 ++++ web/src/App.tsx | 177 +++++--- web/src/LoginModal.tsx | 124 ++++++ web/src/UserSettings.tsx | 254 +++++++++++ web/src/styles.css | 593 +++++++++++++++++++++++++ 7 files changed, 1435 insertions(+), 60 deletions(-) create mode 100644 server/src/core/discord-auth.ts create mode 100644 web/src/LoginModal.tsx create mode 100644 web/src/UserSettings.tsx diff --git a/server/src/core/discord-auth.ts b/server/src/core/discord-auth.ts new file mode 100644 index 0000000..253a10a --- /dev/null +++ b/server/src/core/discord-auth.ts @@ -0,0 +1,247 @@ +// ────────────────────────────────────────────────────────────────────────────── +// Discord OAuth2 Authentication + Unified Session Management +// ────────────────────────────────────────────────────────────────────────────── +import crypto from 'node:crypto'; +import type express from 'express'; + +// ── Config ── +const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? ''; +const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? ''; +const DISCORD_API = 'https://discord.com/api/v10'; +const DISCORD_AUTH_URL = 'https://discord.com/oauth2/authorize'; +const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`; +const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days in seconds +const ADMIN_MAX_AGE = 7 * 24 * 3600; // 7 days in seconds + +// ── Types ── +export interface DiscordUser { + id: string; + username: string; + discriminator: string; + avatar: string | null; + global_name: string | null; +} + +export interface UserSession { + provider: 'discord' | 'admin'; + discordId?: string; + username?: string; + avatar?: string | null; + globalName?: string | null; + iat: number; + exp: number; +} + +// ── Helpers ── +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +function readCookie(req: express.Request, key: string): string | undefined { + const c = req.headers.cookie; + if (!c) return undefined; + for (const part of c.split(';')) { + const [k, v] = part.trim().split('='); + if (k === key) return decodeURIComponent(v || ''); + } + return undefined; +} + +// ── Session Token (HMAC-SHA256) ── +// Uses ADMIN_PWD as base secret, with a salt to differentiate from admin tokens +const SESSION_SECRET = (process.env.ADMIN_PWD ?? '') + ':hub_session_v1'; + +export function signSession(session: UserSession): string { + const body = b64url(JSON.stringify(session)); + const sig = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +export function verifySession(token: string | undefined): UserSession | null { + if (!token) return null; + const [body, sig] = token.split('.'); + if (!body || !sig) return null; + const expected = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url'); + if (expected !== sig) return null; + try { + const session = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as UserSession; + if (typeof session.exp === 'number' && Date.now() < session.exp) return session; + return null; + } catch { return null; } +} + +export function getSession(req: express.Request): UserSession | null { + return verifySession(readCookie(req, 'hub_session')); +} + +// ── Admin Token (backward compat with soundboard plugin) ── +function signAdminTokenCompat(adminPwd: string): string { + const payload = { iat: Date.now(), exp: Date.now() + ADMIN_MAX_AGE * 1000 }; + const body = b64url(JSON.stringify(payload)); + const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +// ── Discord OAuth2 ── +function getRedirectUri(): string { + const publicUrl = process.env.PUBLIC_URL ?? ''; + if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/discord/callback`; + return `http://localhost:${process.env.PORT ?? 8080}/api/auth/discord/callback`; +} + +export function isDiscordConfigured(): boolean { + return !!(DISCORD_CLIENT_ID && DISCORD_CLIENT_SECRET); +} + +function getDiscordAuthUrl(state: string): string { + const params = new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + redirect_uri: getRedirectUri(), + response_type: 'code', + scope: 'identify', + state, + }); + return `${DISCORD_AUTH_URL}?${params.toString()}`; +} + +async function exchangeDiscordCode(code: string): Promise { + const res = await fetch(DISCORD_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + client_secret: DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: getRedirectUri(), + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Discord token exchange failed (${res.status}): ${text}`); + } + const data = await res.json() as { access_token: string }; + return data.access_token; +} + +async function fetchDiscordUser(accessToken: string): Promise { + const res = await fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Discord user fetch failed (${res.status}): ${text}`); + } + return await res.json() as DiscordUser; +} + +// ── Register Routes ── +export function registerAuthRoutes(app: express.Application, adminPwd: string): void { + + // Available providers + app.get('/api/auth/providers', (_req, res) => { + res.json({ + discord: isDiscordConfigured(), + steam: false, // Steam OpenID — planned + admin: !!adminPwd, + }); + }); + + // Current session + app.get('/api/auth/me', (req, res) => { + const session = getSession(req); + if (!session) { + res.json({ authenticated: false }); + return; + } + res.json({ + authenticated: true, + provider: session.provider, + discordId: session.discordId ?? null, + username: session.username ?? null, + avatar: session.avatar ?? null, + globalName: session.globalName ?? null, + isAdmin: session.provider === 'admin', + }); + }); + + // Discord OAuth2 — start + app.get('/api/auth/discord', (_req, res) => { + if (!isDiscordConfigured()) { + res.status(503).json({ error: 'Discord OAuth nicht konfiguriert (DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET fehlen)' }); + return; + } + const state = crypto.randomBytes(16).toString('hex'); + console.log(`[Auth] Discord OAuth2 redirect → ${getRedirectUri()}`); + res.redirect(getDiscordAuthUrl(state)); + }); + + // Discord OAuth2 — callback + app.get('/api/auth/discord/callback', async (req, res) => { + const code = req.query.code as string | undefined; + if (!code) { + res.status(400).send('Kein Authorization-Code erhalten.'); + return; + } + try { + const accessToken = await exchangeDiscordCode(code); + const user = await fetchDiscordUser(accessToken); + + const avatarUrl = user.avatar + ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128` + : null; + + const session: UserSession = { + provider: 'discord', + discordId: user.id, + username: user.username, + avatar: avatarUrl, + globalName: user.global_name, + iat: Date.now(), + exp: Date.now() + SESSION_MAX_AGE * 1000, + }; + + const token = signSession(session); + res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`); + console.log(`[Auth] Discord login: ${user.username} (${user.id})`); + res.redirect('/'); + } catch (e) { + console.error('[Auth] Discord callback error:', e); + res.status(500).send('Discord Login fehlgeschlagen. Bitte erneut versuchen.'); + } + }); + + // Admin login (via unified modal) + app.post('/api/auth/admin', (req, res) => { + if (!adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } + const { password } = req.body ?? {}; + if (!password || password !== adminPwd) { + res.status(401).json({ error: 'Falsches Passwort' }); + return; + } + const session: UserSession = { + provider: 'admin', + username: 'Admin', + iat: Date.now(), + exp: Date.now() + ADMIN_MAX_AGE * 1000, + }; + const hubToken = signSession(session); + const adminToken = signAdminTokenCompat(adminPwd); + // Set hub_session AND legacy admin cookie (soundboard plugin reads 'admin' cookie) + res.setHeader('Set-Cookie', [ + `hub_session=${encodeURIComponent(hubToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`, + `admin=${encodeURIComponent(adminToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`, + ]); + console.log('[Auth] Admin login'); + res.json({ ok: true }); + }); + + // Logout (clears all session cookies) + app.post('/api/auth/logout', (_req, res) => { + res.setHeader('Set-Cookie', [ + 'hub_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax', + 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax', + ]); + res.json({ ok: true }); + }); +} diff --git a/server/src/index.ts b/server/src/index.ts index 6c322c2..c1676bb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,7 @@ import { createClient } from './core/discord.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { loadState, getFullState, getStateDiag } from './core/persistence.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; +import { registerAuthRoutes } from './core/discord-auth.js'; import radioPlugin from './plugins/radio/index.js'; import soundboardPlugin from './plugins/soundboard/index.js'; import lolstatsPlugin from './plugins/lolstats/index.js'; @@ -130,6 +131,9 @@ function onClientReady(botName: string, client: Client): void { // ── Init ── async function boot(): Promise { + // ── Auth routes (before plugins so /api/auth/* is available) ── + registerAuthRoutes(app, ADMIN_PWD); + // ── Register plugins with their bot contexts ── registerPlugin(soundboardPlugin, ctxJukebox); registerPlugin(radioPlugin, ctxRadio); diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index 77b49e3..ea050d7 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -17,6 +17,7 @@ import nacl from 'tweetnacl'; import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; +import { getSession } from '../../core/discord-auth.js'; // ── Config (env) ── const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; @@ -1244,6 +1245,101 @@ const soundboardPlugin: Plugin = { }); }); + // ── User Sound Preferences (Discord-authenticated) ── + // Get current user's entrance/exit sounds + app.get('/api/soundboard/user/sounds', (req, res) => { + const session = getSession(req); + if (!session?.discordId) { + res.status(401).json({ error: 'Nicht eingeloggt' }); + return; + } + const userId = session.discordId; + const entrance = persistedState.entranceSounds?.[userId] ?? null; + const exit = persistedState.exitSounds?.[userId] ?? null; + res.json({ entrance, exit }); + }); + + // Set entrance sound + app.post('/api/soundboard/user/entrance', (req, res) => { + const session = getSession(req); + if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } + const { fileName } = req.body ?? {}; + if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; } + if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; } + // Resolve file path (same logic as DM handler) + const resolve = (() => { + try { + if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName; + for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`; + } + return ''; + } catch { return ''; } + })(); + if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; } + persistedState.entranceSounds = persistedState.entranceSounds ?? {}; + persistedState.entranceSounds[session.discordId] = resolve; + writeState(); + console.log(`[Soundboard] User ${session.username} (${session.discordId}) set entrance: ${resolve}`); + res.json({ ok: true, entrance: resolve }); + }); + + // Set exit sound + app.post('/api/soundboard/user/exit', (req, res) => { + const session = getSession(req); + if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } + const { fileName } = req.body ?? {}; + if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; } + if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; } + const resolve = (() => { + try { + if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName; + for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`; + } + return ''; + } catch { return ''; } + })(); + if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; } + persistedState.exitSounds = persistedState.exitSounds ?? {}; + persistedState.exitSounds[session.discordId] = resolve; + writeState(); + console.log(`[Soundboard] User ${session.username} (${session.discordId}) set exit: ${resolve}`); + res.json({ ok: true, exit: resolve }); + }); + + // Remove entrance sound + app.delete('/api/soundboard/user/entrance', (req, res) => { + const session = getSession(req); + if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } + if (persistedState.entranceSounds) { + delete persistedState.entranceSounds[session.discordId]; + writeState(); + } + console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed entrance sound`); + res.json({ ok: true }); + }); + + // Remove exit sound + app.delete('/api/soundboard/user/exit', (req, res) => { + const session = getSession(req); + if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } + if (persistedState.exitSounds) { + delete persistedState.exitSounds[session.discordId]; + writeState(); + } + console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed exit sound`); + res.json({ ok: true }); + }); + + // List available sounds (for user settings dropdown) - no auth required + app.get('/api/soundboard/user/available-sounds', (_req, res) => { + const allSounds = listAllSounds(); + res.json(allSounds.map(s => ({ name: s.name, fileName: s.fileName, folder: s.folder, relativePath: s.relativePath }))); + }); + // ── Health ── app.get('/api/soundboard/health', (_req, res) => { res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length }); diff --git a/web/src/App.tsx b/web/src/App.tsx index b1290e2..065dc9b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,8 @@ import StreamingTab from './plugins/streaming/StreamingTab'; import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab'; import GameLibraryTab from './plugins/game-library/GameLibraryTab'; import AdminPanel from './AdminPanel'; +import LoginModal from './LoginModal'; +import UserSettings from './UserSettings'; interface PluginInfo { name: string; @@ -13,6 +15,22 @@ interface PluginInfo { description: string; } +interface AuthUser { + authenticated: boolean; + provider?: 'discord' | 'admin'; + discordId?: string; + username?: string; + avatar?: string | null; + globalName?: string | null; + isAdmin?: boolean; +} + +interface AuthProviders { + discord: boolean; + steam: boolean; + admin: boolean; +} + // Plugin tab components const tabComponents: Record> = { radio: RadioTab, @@ -41,12 +59,16 @@ export default function App() { const [showVersionModal, setShowVersionModal] = useState(false); const [pluginData, setPluginData] = useState>({}); - // Centralized admin login state - const [isAdmin, setIsAdmin] = useState(false); - const [showAdminLogin, setShowAdminLogin] = useState(false); + // ── Unified Auth State ── + const [user, setUser] = useState({ authenticated: false }); + const [providers, setProviders] = useState({ discord: false, steam: false, admin: false }); + const [showLoginModal, setShowLoginModal] = useState(false); + const [showUserSettings, setShowUserSettings] = useState(false); const [showAdminPanel, setShowAdminPanel] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [adminError, setAdminError] = useState(''); + + // Derived state + const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true); + const isDiscordUser = user.authenticated && user.provider === 'discord'; // Electron auto-update state const isElectron = !!(window as any).electronAPI?.isElectron; @@ -62,46 +84,54 @@ export default function App() { } }, []); - // Check admin status on mount (shared cookie — any endpoint works) + // Check auth status + providers on mount useEffect(() => { + fetch('/api/auth/me', { credentials: 'include' }) + .then(r => r.json()) + .then((data: AuthUser) => setUser(data)) + .catch(() => {}); + + fetch('/api/auth/providers') + .then(r => r.json()) + .then((data: AuthProviders) => setProviders(data)) + .catch(() => {}); + + // Also check legacy admin cookie (backward compat) fetch('/api/soundboard/admin/status', { credentials: 'include' }) .then(r => r.json()) - .then(d => setIsAdmin(!!d.authenticated)) + .then(d => { + if (d.authenticated) { + setUser(prev => prev.authenticated ? prev : { authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true }); + } + }) .catch(() => {}); }, []); - // Escape key closes admin login modal - useEffect(() => { - if (!showAdminLogin) return; - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAdminLogin(false); }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [showAdminLogin]); - - async function handleAdminLogin() { - setAdminError(''); + // Admin login handler (for LoginModal) + async function handleAdminLogin(password: string): Promise { try { - const resp = await fetch('/api/soundboard/admin/login', { + const resp = await fetch('/api/auth/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: adminPwd }), + body: JSON.stringify({ password }), credentials: 'include', }); if (resp.ok) { - setIsAdmin(true); - setAdminPwd(''); - setShowAdminLogin(false); - } else { - setAdminError('Falsches Passwort'); + setUser({ authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true }); + return true; } + return false; } catch { - setAdminError('Verbindung fehlgeschlagen'); + return false; } } - async function handleAdminLogout() { - await fetch('/api/soundboard/admin/logout', { method: 'POST', credentials: 'include' }); - setIsAdmin(false); + // Unified logout + async function handleLogout() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); + setUser({ authenticated: false }); + setShowUserSettings(false); + setShowAdminPanel(false); } // Electron auto-update listeners @@ -203,6 +233,17 @@ export default function App() { 'game-library': '\u{1F3AE}', }; + // What happens when the user button is clicked + function handleUserButtonClick() { + if (!user.authenticated) { + setShowLoginModal(true); + } else if (isAdmin) { + setShowAdminPanel(true); + } else if (isDiscordUser) { + setShowUserSettings(true); + } + } + return (
@@ -238,14 +279,34 @@ export default function App() { Desktop App )} + + {/* Unified Login / User button */} +
)} - {showAdminLogin && ( -
setShowAdminLogin(false)}> -
e.stopPropagation()}> -
- {'\uD83D\uDD12'} Admin Login - -
-
- setAdminPwd(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} - autoFocus - /> - {adminError &&

{adminError}

} - -
-
-
+ {/* Login Modal */} + {showLoginModal && ( + setShowLoginModal(false)} + onAdminLogin={handleAdminLogin} + providers={providers} + /> )} + {/* User Settings (Discord users) */} + {showUserSettings && isDiscordUser && user.discordId && ( + setShowUserSettings(false)} + onLogout={handleLogout} + /> + )} + + {/* Admin Panel */} {showAdminPanel && isAdmin && ( - setShowAdminPanel(false)} onLogout={() => { handleAdminLogout(); setShowAdminPanel(false); }} /> + setShowAdminPanel(false)} onLogout={() => { handleLogout(); setShowAdminPanel(false); }} /> )}
diff --git a/web/src/LoginModal.tsx b/web/src/LoginModal.tsx new file mode 100644 index 0000000..561ae0b --- /dev/null +++ b/web/src/LoginModal.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from 'react'; + +interface LoginModalProps { + onClose: () => void; + onAdminLogin: (password: string) => Promise; + providers: { discord: boolean; steam: boolean; admin: boolean }; +} + +export default function LoginModal({ onClose, onAdminLogin, providers }: LoginModalProps) { + const [showAdminForm, setShowAdminForm] = useState(false); + const [adminPwd, setAdminPwd] = useState(''); + const [adminError, setAdminError] = useState(''); + const [loading, setLoading] = useState(false); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (showAdminForm) setShowAdminForm(false); + else onClose(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose, showAdminForm]); + + async function handleAdminSubmit() { + if (!adminPwd.trim()) return; + setLoading(true); + setAdminError(''); + const ok = await onAdminLogin(adminPwd); + setLoading(false); + if (ok) { + setAdminPwd(''); + onClose(); + } else { + setAdminError('Falsches Passwort'); + } + } + + return ( +
+
e.stopPropagation()}> +
+ {'\uD83D\uDD10'} Anmelden + +
+ + {!showAdminForm ? ( +
+

Melde dich an, um deine Einstellungen zu verwalten.

+ +
+ {/* Discord */} + {providers.discord && ( + + + + + Mit Discord anmelden + + )} + + {/* Steam — placeholder */} + + + {/* Admin */} + {providers.admin && ( + + )} +
+ + {!providers.discord && ( +

+ {'\u2139\uFE0F'} Discord Login ist nicht konfiguriert. Der Server braucht DISCORD_CLIENT_ID und DISCORD_CLIENT_SECRET. +

+ )} +
+ ) : ( +
+ +
+ + setAdminPwd(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdminSubmit()} + autoFocus + disabled={loading} + /> + {adminError &&

{adminError}

} + +
+
+ )} +
+
+ ); +} diff --git a/web/src/UserSettings.tsx b/web/src/UserSettings.tsx new file mode 100644 index 0000000..1720c46 --- /dev/null +++ b/web/src/UserSettings.tsx @@ -0,0 +1,254 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface UserInfo { + discordId: string; + username: string; + avatar: string | null; + globalName: string | null; +} + +interface SoundOption { + name: string; + fileName: string; + folder: string; + relativePath: string; +} + +interface UserSettingsProps { + user: UserInfo; + onClose: () => void; + onLogout: () => void; +} + +export default function UserSettings({ user, onClose, onLogout }: UserSettingsProps) { + const [entranceSound, setEntranceSound] = useState(null); + const [exitSound, setExitSound] = useState(null); + const [availableSounds, setAvailableSounds] = useState([]); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState<'entrance' | 'exit' | null>(null); + const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + const [activeSection, setActiveSection] = useState<'entrance' | 'exit'>('entrance'); + + // Fetch current sounds + available sounds + useEffect(() => { + Promise.all([ + fetch('/api/soundboard/user/sounds', { credentials: 'include' }).then(r => r.json()), + fetch('/api/soundboard/user/available-sounds').then(r => r.json()), + ]) + .then(([userSounds, sounds]) => { + setEntranceSound(userSounds.entrance ?? null); + setExitSound(userSounds.exit ?? null); + setAvailableSounds(sounds); + setLoading(false); + }) + .catch(() => { + setMessage({ text: 'Fehler beim Laden der Einstellungen', type: 'error' }); + setLoading(false); + }); + }, []); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose]); + + const showMessage = useCallback((text: string, type: 'success' | 'error') => { + setMessage({ text, type }); + setTimeout(() => setMessage(null), 3000); + }, []); + + async function setSound(type: 'entrance' | 'exit', fileName: string) { + setSaving(type); + try { + const resp = await fetch(`/api/soundboard/user/${type}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName }), + credentials: 'include', + }); + if (resp.ok) { + const data = await resp.json(); + if (type === 'entrance') setEntranceSound(data.entrance); + else setExitSound(data.exit); + showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound gesetzt!`, 'success'); + } else { + const err = await resp.json().catch(() => ({ error: 'Unbekannter Fehler' })); + showMessage(err.error || 'Fehler', 'error'); + } + } catch { + showMessage('Verbindungsfehler', 'error'); + } + setSaving(null); + } + + async function removeSound(type: 'entrance' | 'exit') { + setSaving(type); + try { + const resp = await fetch(`/api/soundboard/user/${type}`, { + method: 'DELETE', + credentials: 'include', + }); + if (resp.ok) { + if (type === 'entrance') setEntranceSound(null); + else setExitSound(null); + showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound entfernt`, 'success'); + } + } catch { + showMessage('Verbindungsfehler', 'error'); + } + setSaving(null); + } + + // Group sounds by folder + const folders = new Map(); + const q = search.toLowerCase(); + for (const s of availableSounds) { + if (q && !s.name.toLowerCase().includes(q) && !s.fileName.toLowerCase().includes(q)) continue; + const key = s.folder || 'Allgemein'; + if (!folders.has(key)) folders.set(key, []); + folders.get(key)!.push(s); + } + // Sort folders alphabetically, "Allgemein" first + const sortedFolders = [...folders.entries()].sort(([a], [b]) => { + if (a === 'Allgemein') return -1; + if (b === 'Allgemein') return 1; + return a.localeCompare(b); + }); + + const currentSound = activeSection === 'entrance' ? entranceSound : exitSound; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+ {user.avatar ? ( + + ) : ( +
{user.username[0]?.toUpperCase()}
+ )} +
+ {user.globalName || user.username} + @{user.username} +
+
+
+ + +
+
+ + {/* Message toast */} + {message && ( +
+ {message.type === 'success' ? '\u2705' : '\u274C'} {message.text} +
+ )} + + {loading ? ( +
+ Lade Einstellungen... +
+ ) : ( +
+ {/* Section tabs */} +
+ + +
+ + {/* Current sound display */} +
+ + Aktuell: {' '} + + {currentSound ? ( + + {'\uD83C\uDFB5'} {currentSound} + + + ) : ( + Kein Sound gesetzt + )} +
+ + {/* Search */} +
+ setSearch(e.target.value)} + /> + {search && ( + + )} +
+ + {/* Sound list */} +
+ {sortedFolders.length === 0 ? ( +
+ {search ? 'Keine Treffer' : 'Keine Sounds verfügbar'} +
+ ) : ( + sortedFolders.map(([folder, sounds]) => ( +
+
{'\uD83D\uDCC1'} {folder}
+
+ {sounds.map(s => { + const isSelected = currentSound === s.relativePath || currentSound === s.fileName; + return ( + + ); + })} +
+
+ )) + )} +
+
+ )} +
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index a1bc6b9..8acdfbc 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2313,3 +2313,596 @@ html, body { gap: 4px; } } + +/* ════════════════════════════════════════════════════════════════════════════ + Unified Login Button (Header) + ════════════════════════════════════════════════════════════════════════════ */ +.hub-user-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: transparent; + color: var(--text-muted); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); + line-height: 1; + white-space: nowrap; +} +.hub-user-btn:hover { + color: var(--accent); + border-color: var(--accent); + background: rgba(var(--accent-rgb), 0.06); +} +.hub-user-btn.logged-in { + border-color: rgba(var(--accent-rgb), 0.3); + color: var(--text-normal); +} +.hub-user-btn.admin { + border-color: #4ade80; + color: #4ade80; +} +.hub-user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; +} +.hub-user-icon { + font-size: 16px; + line-height: 1; +} +.hub-user-label { + line-height: 1; +} + +/* ════════════════════════════════════════════════════════════════════════════ + Login Modal + ════════════════════════════════════════════════════════════════════════════ */ +.hub-login-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} +.hub-login-modal { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 16px; + width: 380px; + max-width: 92vw; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + overflow: hidden; + animation: hub-modal-in 200ms ease; +} +.hub-login-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 15px; +} +.hub-login-modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 4px; + border-radius: 4px; + transition: all var(--transition); +} +.hub-login-modal-close:hover { + color: var(--text-normal); + background: var(--bg-tertiary); +} +.hub-login-modal-body { + padding: 20px; +} +.hub-login-subtitle { + color: var(--text-muted); + font-size: 13px; + margin: 0 0 16px; + line-height: 1.4; +} + +/* Provider Buttons */ +.hub-login-providers { + display: flex; + flex-direction: column; + gap: 10px; +} +.hub-login-provider-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-secondary); + color: var(--text-normal); + font-family: var(--font); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); + text-decoration: none; + position: relative; +} +.hub-login-provider-btn:hover:not(:disabled) { + border-color: var(--accent); + background: rgba(var(--accent-rgb), 0.06); + transform: translateY(-1px); +} +.hub-login-provider-btn:active:not(:disabled) { + transform: translateY(0); +} +.hub-login-provider-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.hub-login-provider-btn.discord:hover:not(:disabled) { + border-color: #5865F2; + background: rgba(88, 101, 242, 0.08); +} +.hub-login-provider-btn.steam:hover:not(:disabled) { + border-color: #1b2838; + background: rgba(27, 40, 56, 0.15); +} +.hub-login-provider-btn.admin:hover { + border-color: var(--accent); +} +.hub-login-provider-icon { + flex-shrink: 0; + width: 22px; + height: 22px; +} +.hub-login-provider-icon-emoji { + font-size: 20px; + line-height: 1; + flex-shrink: 0; +} +.hub-login-soon { + position: absolute; + right: 12px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-faint); + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 4px; + letter-spacing: 0.5px; +} +.hub-login-hint { + margin: 16px 0 0; + font-size: 12px; + color: var(--text-faint); + line-height: 1.4; +} +.hub-login-back { + background: none; + border: none; + color: var(--text-muted); + font-family: var(--font); + font-size: 13px; + cursor: pointer; + padding: 0; + margin-bottom: 16px; + transition: color var(--transition); +} +.hub-login-back:hover { + color: var(--accent); +} + +/* Admin form inside login modal */ +.hub-login-admin-form { + display: flex; + flex-direction: column; + gap: 12px; +} +.hub-login-admin-label { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); +} +.hub-login-admin-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-normal); + font-size: 14px; + font-family: var(--font); + box-sizing: border-box; + transition: border-color var(--transition); +} +.hub-login-admin-input:focus { + outline: none; + border-color: var(--accent); +} +.hub-login-admin-error { + color: var(--danger); + font-size: 13px; + margin: 0; +} +.hub-login-admin-submit { + padding: 10px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 14px; + font-weight: 600; + font-family: var(--font); + cursor: pointer; + transition: opacity var(--transition); +} +.hub-login-admin-submit:hover:not(:disabled) { + opacity: 0.9; +} +.hub-login-admin-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ════════════════════════════════════════════════════════════════════════════ + User Settings Panel + ════════════════════════════════════════════════════════════════════════════ */ +.hub-usettings-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} +.hub-usettings-panel { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 16px; + width: 520px; + max-width: 95vw; + max-height: 85vh; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + overflow: hidden; + display: flex; + flex-direction: column; + animation: hub-modal-in 200ms ease; +} + +/* Header */ +.hub-usettings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.hub-usettings-user { + display: flex; + align-items: center; + gap: 12px; +} +.hub-usettings-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(var(--accent-rgb), 0.3); +} +.hub-usettings-avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--accent); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; +} +.hub-usettings-user-info { + display: flex; + flex-direction: column; + gap: 2px; +} +.hub-usettings-username { + font-weight: 600; + font-size: 15px; + color: var(--text-normal); +} +.hub-usettings-discriminator { + font-size: 12px; + color: var(--text-muted); +} +.hub-usettings-header-actions { + display: flex; + align-items: center; + gap: 8px; +} +.hub-usettings-logout { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 16px; + padding: 6px 8px; + cursor: pointer; + transition: all var(--transition); + line-height: 1; +} +.hub-usettings-logout:hover { + color: var(--danger); + border-color: var(--danger); +} +.hub-usettings-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + padding: 6px; + border-radius: 4px; + transition: all var(--transition); +} +.hub-usettings-close:hover { + color: var(--text-normal); + background: var(--bg-tertiary); +} + +/* Toast */ +.hub-usettings-toast { + padding: 8px 16px; + font-size: 13px; + text-align: center; + animation: hub-toast-in 300ms ease; +} +.hub-usettings-toast.success { + background: rgba(87, 210, 143, 0.1); + color: var(--success); +} +.hub-usettings-toast.error { + background: rgba(237, 66, 69, 0.1); + color: var(--danger); +} +@keyframes hub-toast-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Loading */ +.hub-usettings-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px; + color: var(--text-muted); + font-size: 14px; +} + +/* Content */ +.hub-usettings-content { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; +} + +/* Section tabs */ +.hub-usettings-tabs { + display: flex; + gap: 4px; + padding: 12px 20px 0; + flex-shrink: 0; +} +.hub-usettings-tab { + flex: 1; + padding: 10px 12px; + border: none; + background: var(--bg-secondary); + color: var(--text-muted); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border-radius: var(--radius) var(--radius) 0 0; + transition: all var(--transition); +} +.hub-usettings-tab:hover { + color: var(--text-normal); + background: var(--bg-tertiary); +} +.hub-usettings-tab.active { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); + border-bottom: 2px solid var(--accent); +} + +/* Current sound */ +.hub-usettings-current { + padding: 12px 20px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + background: var(--bg-secondary); + margin: 0 20px; + border-radius: 0 0 var(--radius) var(--radius); + margin-bottom: 12px; +} +.hub-usettings-current-label { + font-size: 13px; + color: var(--text-muted); + flex-shrink: 0; +} +.hub-usettings-current-value { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--accent); +} +.hub-usettings-current-none { + font-size: 13px; + color: var(--text-faint); + font-style: italic; +} +.hub-usettings-remove-btn { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + font-size: 11px; + padding: 2px 4px; + border-radius: 4px; + transition: all var(--transition); +} +.hub-usettings-remove-btn:hover:not(:disabled) { + color: var(--danger); + background: rgba(237, 66, 69, 0.1); +} + +/* Search */ +.hub-usettings-search-wrap { + position: relative; + padding: 0 20px; + margin-bottom: 12px; + flex-shrink: 0; +} +.hub-usettings-search { + width: 100%; + padding: 8px 32px 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-normal); + font-size: 13px; + font-family: var(--font); + box-sizing: border-box; + transition: border-color var(--transition); +} +.hub-usettings-search:focus { + outline: none; + border-color: var(--accent); +} +.hub-usettings-search-clear { + position: absolute; + right: 28px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + font-size: 12px; + padding: 4px; +} +.hub-usettings-search-clear:hover { + color: var(--text-normal); +} + +/* Sound list */ +.hub-usettings-sounds { + flex: 1; + overflow-y: auto; + padding: 0 20px 16px; + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; +} +.hub-usettings-empty { + text-align: center; + padding: 32px; + color: var(--text-faint); + font-size: 14px; +} + +/* Folder */ +.hub-usettings-folder { + margin-bottom: 12px; +} +.hub-usettings-folder-name { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 0; + margin-bottom: 6px; + border-bottom: 1px solid var(--border); +} +.hub-usettings-folder-sounds { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Sound button */ +.hub-usettings-sound-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-normal); + font-family: var(--font); + font-size: 12px; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} +.hub-usettings-sound-btn:hover:not(:disabled) { + border-color: var(--accent); + background: rgba(var(--accent-rgb), 0.06); +} +.hub-usettings-sound-btn.selected { + border-color: var(--accent); + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); +} +.hub-usettings-sound-btn:disabled { + opacity: 0.5; + cursor: wait; +} +.hub-usettings-sound-icon { + font-size: 12px; + line-height: 1; +} +.hub-usettings-sound-name { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Mobile responsive for user settings ── */ +@media (max-width: 600px) { + .hub-usettings-panel { + width: 100%; + max-width: 100vw; + max-height: 100vh; + border-radius: 0; + } + .hub-user-label { + display: none; + } +}