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
This commit is contained in:
@@ -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" }
|
||||
]
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,36 +2,89 @@ import React from 'react';
|
||||
import { usePresence } from '../hooks/usePresence';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
function GearIcon() {
|
||||
return (
|
||||
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubbleIcon() {
|
||||
return (
|
||||
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{isBot ? <ChatBubbleIcon /> : <GearIcon />}
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto px-2 sm:px-4 pt-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||
who's here?
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{viewers.map((name, i) => (
|
||||
<span
|
||||
key={`${name}-${i}`}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
name === 'me'
|
||||
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{hasViewers && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||
who's here?
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{viewers.map((v, i) => (
|
||||
<span
|
||||
key={`${v.name}-${i}`}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
v.name === 'me'
|
||||
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{v.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasViewers && hasServices && (
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600" />
|
||||
)}
|
||||
{hasServices && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||
connected
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{services.map((s, i) => (
|
||||
<ServiceBadge key={`${s.name}-${i}`} name={s.name} role={s.role} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user