From b2bb2989e93290f826e41d92f7efc59af4526240 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 5 Apr 2026 04:27:07 -0400 Subject: [PATCH] feat: role-aware presence bar, WebSocket logging fixes - findAdminByKey returns role from admins.json (defaults to 'admin') - JWT includes config-defined role instead of hardcoded 'admin' - PresenceBar split into "who's here?" (page admins) and "connected" (bot/utility services with icon+color badges) - Bot/utility roles appear in presence on all pages when connected - usePresence hook uses refs to avoid WS reconnect on navigation - WS auth log prints admin name instead of generic 'admin' - WS connection log reads X-Forwarded-For for real client IP - AuthContext stores adminRole from login response - Uncomment admins.json Docker volume mount, add SELinux :z flags Made-with: Cursor --- backend/config/admins.example.json | 5 +- backend/config/load-admins.js | 2 +- backend/routes/auth.js | 3 +- backend/utils/websocket-manager.js | 20 ++++-- docker-compose.yml | 4 +- frontend/src/components/PresenceBar.jsx | 93 ++++++++++++++++++------ frontend/src/context/AuthContext.jsx | 11 ++- frontend/src/hooks/usePresence.js | 27 ++++--- tests/api/named-admins.test.js | 95 +++++++++++++++++++++---- 9 files changed, 207 insertions(+), 53 deletions(-) diff --git a/backend/config/admins.example.json b/backend/config/admins.example.json index 96df42d..9c29e8c 100644 --- a/backend/config/admins.example.json +++ b/backend/config/admins.example.json @@ -1,4 +1,5 @@ [ - { "name": "Alice", "key": "change-me-alice-key" }, - { "name": "Bob", "key": "change-me-bob-key" } + { "name": "Alice", "role": "admin", "key": "change-me-alice-key" }, + { "name": "Bob", "role": "bot", "key": "change-me-bob-key" }, + { "name": "Charlie", "role": "utility", "key": "change-me-charlie-key" } ] diff --git a/backend/config/load-admins.js b/backend/config/load-admins.js index da433a3..b858bf3 100644 --- a/backend/config/load-admins.js +++ b/backend/config/load-admins.js @@ -62,7 +62,7 @@ const admins = loadAdmins(); function findAdminByKey(key) { const match = admins.find(a => a.key === key); - return match ? { name: match.name } : null; + return match ? { name: match.name, role: match.role || 'admin' } : null; } module.exports = { findAdminByKey, admins }; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 859d03d..13592f5 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -18,7 +18,7 @@ router.post('/login', (req, res) => { } const token = jwt.sign( - { role: 'admin', name: admin.name, timestamp: Date.now() }, + { role: admin.role, name: admin.name, timestamp: Date.now() }, JWT_SECRET, { expiresIn: '24h' } ); @@ -26,6 +26,7 @@ router.post('/login', (req, res) => { res.json({ token, name: admin.name, + role: admin.role, message: 'Authentication successful', expiresIn: '24h' }); diff --git a/backend/utils/websocket-manager.js b/backend/utils/websocket-manager.js index bba5e62..ecfa3ed 100644 --- a/backend/utils/websocket-manager.js +++ b/backend/utils/websocket-manager.js @@ -26,13 +26,17 @@ class WebSocketManager { * Handle new WebSocket connection */ handleConnection(ws, req) { - console.log('[WebSocket] New connection from', req.socket.remoteAddress); + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() + || req.headers['x-real-ip'] + || req.socket.remoteAddress; + console.log('[WebSocket] New connection from', clientIp); // Initialize client info const clientInfo = { authenticated: false, userId: null, adminName: null, + role: null, currentPage: null, subscribedSessions: new Set(), lastPing: Date.now() @@ -130,6 +134,7 @@ class WebSocketManager { clientInfo.authenticated = true; clientInfo.userId = decoded.role; clientInfo.adminName = decoded.name || null; + clientInfo.role = decoded.role || 'admin'; if (!decoded.name) { this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error'); @@ -141,7 +146,8 @@ class WebSocketManager { message: 'Authenticated successfully' }); - console.log('[WebSocket] Client authenticated:', clientInfo.userId); + console.log('[WebSocket] Client authenticated:', clientInfo.adminName); + this.broadcastPresence(); } } catch (err) { console.error('[WebSocket] Authentication failed:', err.message); @@ -299,7 +305,7 @@ class WebSocketManager { }); this.clients.delete(ws); - console.log('[WebSocket] Client disconnected and cleaned up'); + console.log('[WebSocket] Client disconnected:', clientInfo.adminName || 'unauthenticated'); this.broadcastPresence(); } } @@ -307,8 +313,12 @@ class WebSocketManager { broadcastPresence() { const admins = []; this.clients.forEach((info) => { - if (info.authenticated && info.adminName && info.currentPage) { - admins.push({ name: info.adminName, page: info.currentPage }); + if (!info.authenticated || !info.adminName) return; + const role = info.role || 'admin'; + if (role === 'bot' || role === 'utility') { + admins.push({ name: info.adminName, role, page: null }); + } else if (info.currentPage) { + admins.push({ name: info.adminName, role, page: info.currentPage }); } }); diff --git a/docker-compose.yml b/docker-compose.yml index af7ffcb..8037196 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,8 @@ services: - DEBUG=false volumes: - jackbox-data:/app/data - - ./games-list.csv:/app/games-list.csv:ro - # - ./backend/config/admins.json:/app/config/admins.json:ro + - ./games-list.csv:/app/games-list.csv:ro,z + - ./backend/config/admins.json:/app/config/admins.json:ro,z ports: - "5000:5000" networks: diff --git a/frontend/src/components/PresenceBar.jsx b/frontend/src/components/PresenceBar.jsx index 4dc95ba..f9a0bb5 100644 --- a/frontend/src/components/PresenceBar.jsx +++ b/frontend/src/components/PresenceBar.jsx @@ -2,36 +2,89 @@ import React from 'react'; import { usePresence } from '../hooks/usePresence'; import { useAuth } from '../context/AuthContext'; +function GearIcon() { + return ( + + + + + ); +} + +function ChatBubbleIcon() { + return ( + + + + ); +} + +function ServiceBadge({ name, role }) { + const isBot = role === 'bot'; + const colorClass = isBot + ? 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300' + : 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'; + + return ( + + {isBot ? : } + {name} + + ); +} + function PresenceBar() { const { isAuthenticated } = useAuth(); - const { viewers } = usePresence(); + const { viewers, services } = usePresence(); if (!isAuthenticated) return null; - const otherViewers = viewers.filter(v => v !== 'me'); - if (otherViewers.length === 0) return null; + const otherViewers = viewers.filter(v => v.name !== 'me'); + const hasViewers = otherViewers.length > 0; + const hasServices = services.length > 0; + + if (!hasViewers && !hasServices) return null; return (
-
- - who's here? - -
- {viewers.map((name, i) => ( - - {name} +
+ {hasViewers && ( +
+ + who's here? - ))} -
+
+ {viewers.map((v, i) => ( + + {v.name} + + ))} +
+
+ )} + {hasViewers && hasServices && ( +
+ )} + {hasServices && ( +
+ + connected + +
+ {services.map((s, i) => ( + + ))} +
+
+ )}
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 612985b..d37a115 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -15,6 +15,7 @@ export const useAuth = () => { export const AuthProvider = ({ children }) => { const [token, setToken] = useState(localStorage.getItem('adminToken')); const [adminName, setAdminName] = useState(localStorage.getItem('adminName')); + const [adminRole, setAdminRole] = useState(localStorage.getItem('adminRole')); const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(true); @@ -27,9 +28,12 @@ export const AuthProvider = ({ children }) => { }); setIsAuthenticated(true); const name = response.data.user?.name; + const role = response.data.user?.role || 'admin'; if (name) { setAdminName(name); + setAdminRole(role); localStorage.setItem('adminName', name); + localStorage.setItem('adminRole', role); } else { logout(); } @@ -47,11 +51,13 @@ export const AuthProvider = ({ children }) => { const login = async (key) => { try { const response = await axios.post('/api/auth/login', { key }); - const { token: newToken, name } = response.data; + const { token: newToken, name, role } = response.data; localStorage.setItem('adminToken', newToken); localStorage.setItem('adminName', name); + localStorage.setItem('adminRole', role || 'admin'); setToken(newToken); setAdminName(name); + setAdminRole(role || 'admin'); setIsAuthenticated(true); migratePreferences(name); return { success: true }; @@ -66,14 +72,17 @@ export const AuthProvider = ({ children }) => { const logout = () => { localStorage.removeItem('adminToken'); localStorage.removeItem('adminName'); + localStorage.removeItem('adminRole'); setToken(null); setAdminName(null); + setAdminRole(null); setIsAuthenticated(false); }; const value = { token, adminName, + adminRole, isAuthenticated, loading, login, diff --git a/frontend/src/hooks/usePresence.js b/frontend/src/hooks/usePresence.js index f1c22d5..1121208 100644 --- a/frontend/src/hooks/usePresence.js +++ b/frontend/src/hooks/usePresence.js @@ -9,9 +9,15 @@ export function usePresence() { const { token, adminName, isAuthenticated } = useAuth(); const location = useLocation(); const [viewers, setViewers] = useState([]); + const [services, setServices] = useState([]); const wsRef = useRef(null); const pingRef = useRef(null); const reconnectRef = useRef(null); + const locationRef = useRef(location.pathname); + const adminNameRef = useRef(adminName); + + locationRef.current = location.pathname; + adminNameRef.current = adminName; const getWsUrl = useCallback(() => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -32,7 +38,7 @@ export function usePresence() { const msg = JSON.parse(event.data); if (msg.type === 'auth_success') { - ws.send(JSON.stringify({ type: 'page_focus', page: location.pathname })); + ws.send(JSON.stringify({ type: 'page_focus', page: locationRef.current })); pingRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { @@ -42,11 +48,16 @@ export function usePresence() { } if (msg.type === 'presence_update') { - const currentPage = location.pathname; - const onSamePage = msg.admins - .filter(a => a.page === currentPage) - .map(a => a.name === adminName ? 'me' : a.name); - setViewers(onSamePage); + const currentPage = locationRef.current; + const currentName = adminNameRef.current; + const pageViewers = msg.admins + .filter(a => a.role !== 'bot' && a.role !== 'utility' && a.page === currentPage) + .map(a => ({ name: a.name === currentName ? 'me' : a.name, role: a.role || 'admin' })); + const connectedServices = msg.admins + .filter(a => a.role === 'bot' || a.role === 'utility') + .map(a => ({ name: a.name, role: a.role })); + setViewers(pageViewers); + setServices(connectedServices); } }; @@ -58,7 +69,7 @@ export function usePresence() { ws.onerror = () => { ws.close(); }; - }, [isAuthenticated, token, adminName, location.pathname, getWsUrl]); + }, [isAuthenticated, token, getWsUrl]); useEffect(() => { connect(); @@ -79,5 +90,5 @@ export function usePresence() { } }, [location.pathname]); - return { viewers }; + return { viewers, services }; } diff --git a/tests/api/named-admins.test.js b/tests/api/named-admins.test.js index a0e6ab9..a8e5026 100644 --- a/tests/api/named-admins.test.js +++ b/tests/api/named-admins.test.js @@ -26,23 +26,33 @@ describe('load-admins', () => { test('loads admins from ADMIN_CONFIG_PATH', () => { const configPath = writeConfig([ - { name: 'Alice', key: 'key-a' }, - { name: 'Bob', key: 'key-b' } + { name: 'Alice', role: 'admin', key: 'key-a' }, + { name: 'Bob', role: 'bot', key: 'key-b' } ]); process.env.ADMIN_CONFIG_PATH = configPath; const { findAdminByKey } = require('../../backend/config/load-admins'); - expect(findAdminByKey('key-a')).toEqual({ name: 'Alice' }); - expect(findAdminByKey('key-b')).toEqual({ name: 'Bob' }); + expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' }); + expect(findAdminByKey('key-b')).toEqual({ name: 'Bob', role: 'bot' }); expect(findAdminByKey('wrong')).toBeNull(); }); + test('defaults role to admin when not specified', () => { + const configPath = writeConfig([ + { name: 'Alice', key: 'key-a' } + ]); + process.env.ADMIN_CONFIG_PATH = configPath; + + const { findAdminByKey } = require('../../backend/config/load-admins'); + expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' }); + }); + test('falls back to ADMIN_KEY when no config file', () => { process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json'); process.env.ADMIN_KEY = 'legacy-key'; const { findAdminByKey } = require('../../backend/config/load-admins'); - expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin' }); + expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin', role: 'admin' }); expect(findAdminByKey('wrong')).toBeNull(); }); @@ -91,7 +101,7 @@ describe('POST /api/auth/login — named admins', () => { ({ app } = require('../../backend/server')); }); - test('login returns admin name in response', async () => { + test('login returns admin name and role in response', async () => { const res = await request(app) .post('/api/auth/login') .set('Content-Type', 'application/json') @@ -99,10 +109,11 @@ describe('POST /api/auth/login — named admins', () => { expect(res.status).toBe(200); expect(res.body.name).toBeDefined(); + expect(res.body.role).toBe('admin'); expect(res.body.token).toBeDefined(); }); - test('verify returns admin name in user object', async () => { + test('verify returns admin name and role in user object', async () => { const loginRes = await request(app) .post('/api/auth/login') .set('Content-Type', 'application/json') @@ -114,6 +125,7 @@ describe('POST /api/auth/login — named admins', () => { expect(res.status).toBe(200); expect(res.body.user.name).toBeDefined(); + expect(res.body.user.role).toBe('admin'); }); test('invalid key still returns 401', async () => { @@ -155,15 +167,15 @@ describe('WebSocket presence', () => { server.close(done); }); - function makeToken(name) { - return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' }); + function makeToken(name, role = 'admin') { + return jwt.sign({ role, name }, process.env.JWT_SECRET, { expiresIn: '1h' }); } - function connectAndAuth(name) { + function connectAndAuth(name, role = 'admin') { return new Promise((resolve) => { const ws = new WebSocket(wsUrl); ws.on('open', () => { - ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) })); + ws.send(JSON.stringify({ type: 'auth', token: makeToken(name, role) })); }); ws.on('message', (data) => { const msg = JSON.parse(data.toString()); @@ -187,7 +199,7 @@ describe('WebSocket presence', () => { }); } - test('page_focus triggers presence_update with admin name and page', async () => { + test('page_focus triggers presence_update with admin name, role, and page', async () => { const ws1 = await connectAndAuth('Alice'); const ws2 = await connectAndAuth('Bob'); @@ -198,7 +210,7 @@ describe('WebSocket presence', () => { const msg = await presencePromise; expect(msg.admins).toEqual( expect.arrayContaining([ - expect.objectContaining({ name: 'Alice', page: '/history' }) + expect.objectContaining({ name: 'Alice', role: 'admin', page: '/history' }) ]) ); @@ -224,4 +236,61 @@ describe('WebSocket presence', () => { ws2.close(); }); + + test('bot role appears in presence without page_focus', async () => { + const wsAdmin = await connectAndAuth('Alice'); + const wsBot = await connectAndAuth('ChatBot', 'bot'); + + wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' })); + + await new Promise(r => setTimeout(r, 100)); + + const presencePromise = waitForMessage(wsAdmin, 'presence_update'); + wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' })); + + const msg = await presencePromise; + const botEntry = msg.admins.find(a => a.name === 'ChatBot'); + expect(botEntry).toBeDefined(); + expect(botEntry.role).toBe('bot'); + expect(botEntry.page).toBeNull(); + + wsAdmin.close(); + wsBot.close(); + }); + + test('utility role appears in presence without page_focus', async () => { + const wsAdmin = await connectAndAuth('Alice'); + const wsUtil = await connectAndAuth('OBS', 'utility'); + + wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' })); + + await new Promise(r => setTimeout(r, 100)); + + const presencePromise = waitForMessage(wsAdmin, 'presence_update'); + wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' })); + + const msg = await presencePromise; + const utilEntry = msg.admins.find(a => a.name === 'OBS'); + expect(utilEntry).toBeDefined(); + expect(utilEntry.role).toBe('utility'); + expect(utilEntry.page).toBeNull(); + + wsAdmin.close(); + wsUtil.close(); + }); + + test('admin without page_focus is NOT in presence', async () => { + const ws1 = await connectAndAuth('Alice'); + const ws2 = await connectAndAuth('Bob'); + + const presencePromise = waitForMessage(ws1, 'presence_update'); + ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' })); + + const msg = await presencePromise; + const bobEntry = msg.admins.find(a => a.name === 'Bob'); + expect(bobEntry).toBeUndefined(); + + ws1.close(); + ws2.close(); + }); });