feat: Discord OAuth Login + User Settings GUI
All checks were successful
Build & Deploy / build (push) Successful in 44s
Build & Deploy / deploy (push) Successful in 5s
Build & Deploy / bump-version (push) Successful in 2s

- 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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-10 20:41:16 +01:00
parent a7e8407996
commit 99d69f30ba
7 changed files with 1435 additions and 60 deletions

View file

@ -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<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
radio: RadioTab,
@ -41,12 +59,16 @@ export default function App() {
const [showVersionModal, setShowVersionModal] = useState(false);
const [pluginData, setPluginData] = useState<Record<string, any>>({});
// Centralized admin login state
const [isAdmin, setIsAdmin] = useState(false);
const [showAdminLogin, setShowAdminLogin] = useState(false);
// ── Unified Auth State ──
const [user, setUser] = useState<AuthUser>({ authenticated: false });
const [providers, setProviders] = useState<AuthProviders>({ 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<boolean> {
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 (
<div className="hub-app">
<header className="hub-header">
@ -238,14 +279,34 @@ export default function App() {
<span className="hub-download-label">Desktop App</span>
</a>
)}
{/* Unified Login / User button */}
<button
className={`hub-admin-btn ${isAdmin ? 'active' : ''}`}
onClick={() => isAdmin ? setShowAdminPanel(true) : setShowAdminLogin(true)}
onContextMenu={e => { if (isAdmin) { e.preventDefault(); handleAdminLogout(); } }}
title={isAdmin ? 'Admin Panel (Rechtsklick = Abmelden)' : 'Admin Login'}
className={`hub-user-btn ${user.authenticated ? 'logged-in' : ''} ${isAdmin ? 'admin' : ''}`}
onClick={handleUserButtonClick}
onContextMenu={e => {
if (user.authenticated) { e.preventDefault(); handleLogout(); }
}}
title={
user.authenticated
? `${user.globalName || user.username || 'Admin'} (Rechtsklick = Abmelden)`
: 'Anmelden'
}
>
{isAdmin ? '\uD83D\uDD13' : '\uD83D\uDD12'}
{user.authenticated ? (
isDiscordUser && user.avatar ? (
<img src={user.avatar} alt="" className="hub-user-avatar" />
) : (
<span className="hub-user-icon">{isAdmin ? '\uD83D\uDD27' : '\uD83D\uDC64'}</span>
)
) : (
<>
<span className="hub-user-icon">{'\uD83D\uDD12'}</span>
<span className="hub-user-label">Login</span>
</>
)}
</button>
<button
className="hub-refresh-btn"
onClick={() => window.location.reload()}
@ -365,36 +426,32 @@ export default function App() {
</div>
)}
{showAdminLogin && (
<div className="hub-admin-overlay" onClick={() => setShowAdminLogin(false)}>
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
<div className="hub-admin-modal-header">
<span>{'\uD83D\uDD12'} Admin Login</span>
<button className="hub-admin-modal-close" onClick={() => setShowAdminLogin(false)}>
{'\u2715'}
</button>
</div>
<div className="hub-admin-modal-body">
<input
type="password"
className="hub-admin-input"
placeholder="Admin-Passwort..."
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
autoFocus
/>
{adminError && <p className="hub-admin-error">{adminError}</p>}
<button className="hub-admin-submit" onClick={handleAdminLogin}>
Login
</button>
</div>
</div>
</div>
{/* Login Modal */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onAdminLogin={handleAdminLogin}
providers={providers}
/>
)}
{/* User Settings (Discord users) */}
{showUserSettings && isDiscordUser && user.discordId && (
<UserSettings
user={{
discordId: user.discordId,
username: user.username ?? '',
avatar: user.avatar ?? null,
globalName: user.globalName ?? null,
}}
onClose={() => setShowUserSettings(false)}
onLogout={handleLogout}
/>
)}
{/* Admin Panel */}
{showAdminPanel && isAdmin && (
<AdminPanel onClose={() => setShowAdminPanel(false)} onLogout={() => { handleAdminLogout(); setShowAdminPanel(false); }} />
<AdminPanel onClose={() => setShowAdminPanel(false)} onLogout={() => { handleLogout(); setShowAdminPanel(false); }} />
)}
<main className="hub-content">