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();
+ });
});