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 <noreply@anthropic.com>
This commit is contained in:
parent
a7e8407996
commit
99d69f30ba
7 changed files with 1435 additions and 60 deletions
177
web/src/App.tsx
177
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<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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue