Refactor: Zentralisiertes Admin-Login im Top-Menü

- Admin-Login aus 3 Plugins (Soundboard, Streaming, Game Library) entfernt
- Zentraler 🔒/🔓 Button im Header mit Login-Modal
- isAdmin wird als Prop an alle Plugins weitergegeben
- Settings-Buttons (Gear-Icons) nur sichtbar wenn eingeloggt
- Alle Plugins nutzen weiterhin den shared admin-Cookie für Operationen
- Login/Logout-Formulare und Buttons aus Plugin-Panels entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-09 21:59:12 +01:00
parent bccfee3de2
commit e9931d82af
10 changed files with 5077 additions and 5063 deletions

4830
web/dist/assets/index-BGKtt2gT.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
web/dist/assets/index-TtdZJHkE.css vendored Normal file

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gaming Hub</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
<script type="module" crossorigin src="/assets/index-BGKtt2gT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-TtdZJHkE.css">
</head>
<body>
<div id="root"></div>

View file

@ -13,7 +13,7 @@ interface PluginInfo {
}
// Plugin tab components
const tabComponents: Record<string, React.FC<{ data: any }>> = {
const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
radio: RadioTab,
soundboard: SoundboardTab,
lolstats: LolstatsTab,
@ -22,7 +22,7 @@ const tabComponents: Record<string, React.FC<{ data: any }>> = {
'game-library': GameLibraryTab,
};
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
export function registerTab(pluginName: string, component: React.FC<{ data: any; isAdmin?: boolean }>) {
tabComponents[pluginName] = component;
}
@ -40,6 +40,12 @@ 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);
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
// Electron auto-update state
const isElectron = !!(window as any).electronAPI?.isElectron;
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
@ -54,6 +60,48 @@ export default function App() {
}
}, []);
// Check admin status on mount (shared cookie — any endpoint works)
useEffect(() => {
fetch('/api/soundboard/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(!!d.authenticated))
.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('');
try {
const resp = await fetch('/api/soundboard/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
setShowAdminLogin(false);
} else {
setAdminError('Falsches Passwort');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}
async function handleAdminLogout() {
await fetch('/api/soundboard/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
}
// Electron auto-update listeners
useEffect(() => {
if (!isElectron) return;
@ -188,6 +236,13 @@ export default function App() {
<span className="hub-download-label">Desktop App</span>
</a>
)}
<button
className={`hub-admin-btn ${isAdmin ? 'active' : ''}`}
onClick={() => isAdmin ? handleAdminLogout() : setShowAdminLogin(true)}
title={isAdmin ? 'Admin abmelden' : 'Admin Login'}
>
{isAdmin ? '\uD83D\uDD13' : '\uD83D\uDD12'}
</button>
<button
className="hub-refresh-btn"
onClick={() => window.location.reload()}
@ -307,6 +362,34 @@ 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>
)}
<main className="hub-content">
{plugins.length === 0 ? (
<div className="hub-empty">
@ -330,7 +413,7 @@ export default function App() {
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} />
<Comp data={pluginData[p.name] || {}} isAdmin={isAdmin} />
</div>
);
})

View file

@ -89,7 +89,7 @@ function formatDate(iso: string): string {
COMPONENT
*/
export default function GameLibraryTab({ data }: { data: any }) {
export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) {
// ── State ──
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
@ -111,11 +111,9 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── Admin state ──
const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const isAdmin = isAdminProp ?? false;
const [adminProfiles, setAdminProfiles] = useState<any[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const [adminError, setAdminError] = useState('');
// ── SSE data sync ──
useEffect(() => {
@ -133,43 +131,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
} 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 ──
const loadAdminProfiles = useCallback(async () => {
setAdminLoading(true);
@ -552,9 +513,11 @@ export default function GameLibraryTab({ data }: { data: any }) {
</button>
)}
<div className="gl-login-bar-spacer" />
{isAdmin && (
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
&#x2699;&#xFE0F;
</button>
)}
</div>
{/* ── Profile Chips ── */}
@ -990,29 +953,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>&#x2715;</button>
</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-toolbar">
<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-logout-btn" onClick={adminLogout}>Logout</button>
</div>
{adminLoading ? (
@ -1044,7 +988,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
</div>
)}
</div>
)}
</div>
</div>
)}

View file

@ -186,25 +186,6 @@ async function apiGetVolume(guildId: string): Promise<number> {
return typeof data?.volume === 'number' ? data.volume : 1;
}
async function apiAdminStatus(): Promise<boolean> {
const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' });
if (!res.ok) return false;
const data = await res.json();
return !!data?.authenticated;
}
async function apiAdminLogin(password: string): Promise<boolean> {
const res = await fetch(`${API_BASE}/admin/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ password })
});
return res.ok;
}
async function apiAdminLogout(): Promise<void> {
await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' });
}
async function apiAdminDelete(paths: string[]): Promise<void> {
const res = await fetch(`${API_BASE}/admin/sounds/delete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
@ -324,13 +305,14 @@ interface VoiceStats {
interface SoundboardTabProps {
data: any;
isAdmin?: boolean;
}
/*
COMPONENT
*/
export default function SoundboardTab({ data }: SoundboardTabProps) {
export default function SoundboardTab({ data, isAdmin: isAdminProp }: SoundboardTabProps) {
/* ── Data ── */
const [sounds, setSounds] = useState<Sound[]>([]);
const [total, setTotal] = useState(0);
@ -378,9 +360,8 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
const volDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
/* ── Admin ── */
const [isAdmin, setIsAdmin] = useState(false);
const isAdmin = isAdminProp ?? false;
const [showAdmin, setShowAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminSounds, setAdminSounds] = useState<Sound[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const [adminQuery, setAdminQuery] = useState('');
@ -521,7 +502,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
}
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
try { setIsAdmin(await apiAdminStatus()); } catch { }
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -879,28 +859,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
}
}
async function handleAdminLogin() {
try {
const ok = await apiAdminLogin(adminPwd);
if (ok) {
setIsAdmin(true);
setAdminPwd('');
notify('Admin eingeloggt');
}
else notify('Falsches Passwort', 'error');
} catch { notify('Login fehlgeschlagen', 'error'); }
}
async function handleAdminLogout() {
try {
await apiAdminLogout();
setIsAdmin(false);
setAdminSelection({});
cancelRename();
notify('Ausgeloggt');
} catch { }
}
/* ── Computed ── */
const displaySounds = useMemo(() => {
if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]);
@ -1040,13 +998,15 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
)}
</div>
)}
{isAdmin && (
<button
className={`admin-btn-icon ${isAdmin ? 'active' : ''}`}
className="admin-btn-icon active"
onClick={() => setShowAdmin(true)}
title="Admin"
>
<span className="material-icons">settings</span>
</button>
)}
</div>
</header>
@ -1447,21 +1407,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
<span className="material-icons" style={{ fontSize: 18 }}>close</span>
</button>
</h3>
{!isAdmin ? (
<div>
<div className="admin-field">
<label>Passwort</label>
<input
type="password"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
placeholder="Admin-Passwort..."
/>
</div>
<button className="admin-btn-action primary" onClick={handleAdminLogin}>Login</button>
</div>
) : (
<div className="admin-shell">
<div className="admin-header-row">
<p className="admin-status">Eingeloggt als Admin</p>
@ -1473,7 +1418,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
>
Aktualisieren
</button>
<button className="admin-btn-action outline" onClick={handleAdminLogout}>Logout</button>
</div>
</div>
@ -1585,7 +1529,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
)}
</div>
</div>
)}
</div>
</div>
)}

View file

@ -55,7 +55,7 @@ const QUALITY_PRESETS = [
// ── Component ──
export default function StreamingTab({ data }: { data: any }) {
export default function StreamingTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) {
// ── State ──
const [streams, setStreams] = useState<StreamInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
@ -74,9 +74,7 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Admin / Notification Config ──
const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
const isAdmin = isAdminProp ?? false;
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 [configLoading, setConfigLoading] = useState(false);
@ -137,12 +135,8 @@ export default function StreamingTab({ data }: { data: any }) {
return () => document.removeEventListener('click', handler);
}, [openMenu]);
// Check admin status on mount
// Check bot status on mount
useEffect(() => {
fetch('/api/notifications/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(d.admin === true))
.catch(() => {});
fetch('/api/notifications/status')
.then(r => r.json())
.then(d => setNotifyStatus(d))
@ -609,35 +603,6 @@ export default function StreamingTab({ data }: { data: any }) {
setOpenMenu(null);
}, [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 () => {
setConfigLoading(true);
try {
@ -806,9 +771,11 @@ export default function StreamingTab({ data }: { data: any }) {
{starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'}
</button>
)}
{isAdmin && (
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
{'\u2699\uFE0F'}
</button>
)}
</div>
{streams.length === 0 && !isBroadcasting ? (
@ -922,24 +889,6 @@ export default function StreamingTab({ data }: { data: any }) {
<button className="stream-admin-close" onClick={() => setShowAdmin(false)}>{'\u2715'}</button>
</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-toolbar">
<span className="stream-admin-status">
@ -947,7 +896,6 @@ export default function StreamingTab({ data }: { data: any }) {
? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></>
: <>{'\u26A0\uFE0F'} Bot offline <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>}
</span>
<button className="stream-admin-logout" onClick={adminLogout}>Logout</button>
</div>
{configLoading ? (
@ -1003,7 +951,6 @@ export default function StreamingTab({ data }: { data: any }) {
</>
)}
</div>
)}
</div>
</div>
)}

View file

@ -353,6 +353,104 @@ html, body {
background: rgba(230, 126, 34, 0.1);
}
/* ── Admin Button (header) ── */
.hub-admin-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 16px;
padding: 4px 8px;
cursor: pointer;
transition: all var(--transition);
line-height: 1;
}
.hub-admin-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.hub-admin-btn.active {
color: #4ade80;
border-color: #4ade80;
}
/* ── Admin Login Modal ── */
.hub-admin-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-admin-modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 340px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.hub-admin-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.hub-admin-modal-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
}
.hub-admin-modal-close:hover {
color: var(--text);
}
.hub-admin-modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.hub-admin-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text);
font-size: 14px;
font-family: var(--font);
box-sizing: border-box;
}
.hub-admin-input:focus {
outline: none;
border-color: var(--accent);
}
.hub-admin-error {
color: #ef4444;
font-size: 13px;
margin: 0;
}
.hub-admin-submit {
padding: 8px 16px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity var(--transition);
}
.hub-admin-submit:hover {
opacity: 0.9;
}
/* ── Version Info Modal ── */
.hub-version-clickable {
cursor: pointer;