Compare commits
11 Commits
512b36da51
...
3da97a39ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da97a39ad
|
||
|
|
04f66a32cc
|
||
|
|
95e7402d81
|
||
|
|
f0b614e28a
|
||
|
|
242150d54c
|
||
|
|
a4d74baf51
|
||
|
|
9f60c6983d
|
||
|
|
fd72c0d7ee
|
||
|
|
ac26ac2ac5
|
||
|
|
0e5c66b98f
|
||
|
|
86725b6f40
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -39,6 +39,9 @@ Thumbs.db
|
|||||||
.local/
|
.local/
|
||||||
.old-chrome-extension/
|
.old-chrome-extension/
|
||||||
|
|
||||||
|
# Admin config (real keys)
|
||||||
|
backend/config/admins.json
|
||||||
|
|
||||||
# Cursor
|
# Cursor
|
||||||
.cursor/
|
.cursor/
|
||||||
chat-summaries/
|
chat-summaries/
|
||||||
|
|||||||
4
backend/config/admins.example.json
Normal file
4
backend/config/admins.example.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
{ "name": "Alice", "key": "change-me-alice-key" },
|
||||||
|
{ "name": "Bob", "key": "change-me-bob-key" }
|
||||||
|
]
|
||||||
55
backend/config/load-admins.js
Normal file
55
backend/config/load-admins.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
|
||||||
|
|
||||||
|
function loadAdmins() {
|
||||||
|
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
||||||
|
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const admins = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (!Array.isArray(admins) || admins.length === 0) {
|
||||||
|
throw new Error(`Admin config at ${configPath} must be a non-empty array`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = new Set();
|
||||||
|
const keys = new Set();
|
||||||
|
|
||||||
|
for (const admin of admins) {
|
||||||
|
if (!admin.name || !admin.key) {
|
||||||
|
throw new Error('Each admin must have a "name" and "key" property');
|
||||||
|
}
|
||||||
|
if (names.has(admin.name)) {
|
||||||
|
throw new Error(`Duplicate admin name: ${admin.name}`);
|
||||||
|
}
|
||||||
|
if (keys.has(admin.key)) {
|
||||||
|
throw new Error(`Duplicate admin key found`);
|
||||||
|
}
|
||||||
|
names.add(admin.name);
|
||||||
|
keys.add(admin.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Auth] Loaded ${admins.length} admin(s) from ${configPath}`);
|
||||||
|
return admins;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.ADMIN_KEY) {
|
||||||
|
console.log('[Auth] No admins config file found, falling back to ADMIN_KEY env var');
|
||||||
|
return [{ name: 'Admin', key: process.env.ADMIN_KEY }];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'No admin configuration found. Provide backend/config/admins.json or set ADMIN_KEY env var.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = loadAdmins();
|
||||||
|
|
||||||
|
function findAdminByKey(key) {
|
||||||
|
const match = admins.find(a => a.key === key);
|
||||||
|
return match ? { name: match.name } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { findAdminByKey, admins };
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
|
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
|
||||||
|
const { findAdminByKey } = require('../config/load-admins');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
if (!process.env.ADMIN_KEY) {
|
|
||||||
throw new Error('ADMIN_KEY environment variable is required');
|
|
||||||
}
|
|
||||||
const ADMIN_KEY = process.env.ADMIN_KEY;
|
|
||||||
|
|
||||||
// Login with admin key
|
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', (req, res) => {
|
||||||
const { key } = req.body;
|
const { key } = req.body;
|
||||||
|
|
||||||
@@ -17,31 +12,34 @@ router.post('/login', (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Admin key is required' });
|
return res.status(400).json({ error: 'Admin key is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key !== ADMIN_KEY) {
|
const admin = findAdminByKey(key);
|
||||||
|
if (!admin) {
|
||||||
return res.status(401).json({ error: 'Invalid admin key' });
|
return res.status(401).json({ error: 'Invalid admin key' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ role: 'admin', timestamp: Date.now() },
|
{ role: 'admin', name: admin.name, timestamp: Date.now() },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
|
name: admin.name,
|
||||||
message: 'Authentication successful',
|
message: 'Authentication successful',
|
||||||
expiresIn: '24h'
|
expiresIn: '24h'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify token validity
|
|
||||||
router.post('/verify', authenticateToken, (req, res) => {
|
router.post('/verify', authenticateToken, (req, res) => {
|
||||||
res.json({
|
if (!req.user.name) {
|
||||||
valid: true,
|
return res.status(403).json({ error: 'Token missing admin identity, please re-login' });
|
||||||
user: req.user
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
valid: true,
|
||||||
|
user: req.user
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class WebSocketManager {
|
|||||||
const clientInfo = {
|
const clientInfo = {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
adminName: null,
|
||||||
|
currentPage: null,
|
||||||
subscribedSessions: new Set(),
|
subscribedSessions: new Set(),
|
||||||
lastPing: Date.now()
|
lastPing: Date.now()
|
||||||
};
|
};
|
||||||
@@ -96,6 +98,15 @@ class WebSocketManager {
|
|||||||
clientInfo.lastPing = Date.now();
|
clientInfo.lastPing = Date.now();
|
||||||
this.send(ws, { type: 'pong' });
|
this.send(ws, { type: 'pong' });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'page_focus':
|
||||||
|
if (!clientInfo.authenticated) {
|
||||||
|
this.sendError(ws, 'Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clientInfo.currentPage = message.page || null;
|
||||||
|
this.broadcastPresence();
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||||
@@ -117,7 +128,13 @@ class WebSocketManager {
|
|||||||
|
|
||||||
if (clientInfo) {
|
if (clientInfo) {
|
||||||
clientInfo.authenticated = true;
|
clientInfo.authenticated = true;
|
||||||
clientInfo.userId = decoded.role; // 'admin' for now
|
clientInfo.userId = decoded.role;
|
||||||
|
clientInfo.adminName = decoded.name || null;
|
||||||
|
|
||||||
|
if (!decoded.name) {
|
||||||
|
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.send(ws, {
|
this.send(ws, {
|
||||||
type: 'auth_success',
|
type: 'auth_success',
|
||||||
@@ -283,9 +300,31 @@ class WebSocketManager {
|
|||||||
|
|
||||||
this.clients.delete(ws);
|
this.clients.delete(ws);
|
||||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
console.log('[WebSocket] Client disconnected and cleaned up');
|
||||||
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broadcastPresence() {
|
||||||
|
const admins = [];
|
||||||
|
this.clients.forEach((info) => {
|
||||||
|
if (info.authenticated && info.adminName && info.currentPage) {
|
||||||
|
admins.push({ name: info.adminName, page: info.currentPage });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'presence_update',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
admins
|
||||||
|
};
|
||||||
|
|
||||||
|
this.clients.forEach((info, ws) => {
|
||||||
|
if (info.authenticated && ws.readyState === ws.OPEN) {
|
||||||
|
this.send(ws, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start heartbeat to detect dead connections
|
* Start heartbeat to detect dead connections
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DB_PATH=/app/data/jackbox.db
|
- DB_PATH=/app/data/jackbox.db
|
||||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
- ADMIN_KEY=${ADMIN_KEY:?ADMIN_KEY is required}
|
- ADMIN_KEY=${ADMIN_KEY:-}
|
||||||
|
- ADMIN_CONFIG_PATH=${ADMIN_CONFIG_PATH:-}
|
||||||
- DEBUG=false
|
- DEBUG=false
|
||||||
volumes:
|
volumes:
|
||||||
- jackbox-data:/app/data
|
- jackbox-data:/app/data
|
||||||
- ./games-list.csv:/app/games-list.csv:ro
|
- ./games-list.csv:/app/games-list.csv:ro
|
||||||
|
# - ./backend/config/admins.json:/app/config/admins.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
1079
docs/superpowers/plans/2026-03-23-named-admins.md
Normal file
1079
docs/superpowers/plans/2026-03-23-named-admins.md
Normal file
File diff suppressed because it is too large
Load Diff
161
docs/superpowers/specs/2026-03-23-named-admins-design.md
Normal file
161
docs/superpowers/specs/2026-03-23-named-admins-design.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Named Admins Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-23
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the single shared `ADMIN_KEY` with named admin accounts, each with their own key. Per-admin preferences are stored in namespaced `localStorage` (except theme, which stays shared — see Edge Cases). A real-time "who is watching" presence bar shows which admins are on the same page.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. **Named admins with multiple keys** — each admin has a name and a unique key, defined in a server-side config file.
|
||||||
|
2. **Per-admin preferences** — UI preferences (saved filter view, show limit, etc.) are linked to the admin who set them via namespaced `localStorage`.
|
||||||
|
3. **Presence badge** — a "who is watching" card in the page header shows which admins are viewing the same page. The current admin sees "me" for themselves, full names for others.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Identity-in-JWT with a config file. No new database tables. Preferences stay in `localStorage` (per-browser, which is desirable). Presence piggybacks on the existing WebSocket infrastructure. Falls back to the old `ADMIN_KEY` env var for backward compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Admin Configuration
|
||||||
|
|
||||||
|
### Config file
|
||||||
|
|
||||||
|
- **`backend/config/admins.example.json`** — committed to repo, shows the expected shape:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "Alice", "key": "change-me-alice-key" },
|
||||||
|
{ "name": "Bob", "key": "change-me-bob-key" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
- **`backend/config/admins.json`** — gitignored, contains real keys.
|
||||||
|
|
||||||
|
### Loader module
|
||||||
|
|
||||||
|
**New file: `backend/config/load-admins.js`**
|
||||||
|
|
||||||
|
Startup behavior:
|
||||||
|
1. Read path from `ADMIN_CONFIG_PATH` env var, or default to `backend/config/admins.json`.
|
||||||
|
2. If the file exists: parse and validate (must be an array of `{ name, key }`, no duplicate names or keys).
|
||||||
|
3. If the file does not exist: fall back to `ADMIN_KEY` env var → `[{ name: "Admin", key: ADMIN_KEY }]`.
|
||||||
|
4. If neither exists: throw at startup (fail-fast, same as current behavior).
|
||||||
|
|
||||||
|
Exports:
|
||||||
|
- `findAdminByKey(key)` — returns `{ name }` or `null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Authentication Changes (Backend)
|
||||||
|
|
||||||
|
### Login endpoint (`backend/routes/auth.js`)
|
||||||
|
|
||||||
|
- Replace single `ADMIN_KEY` comparison with `findAdminByKey(key)`.
|
||||||
|
- On match, embed the admin name in the JWT payload: `{ role: 'admin', name: 'Alice', timestamp: Date.now() }`.
|
||||||
|
- Add `name` to the login response JSON: `{ token, name, message, expiresIn }`.
|
||||||
|
- Startup guard changes from checking `ADMIN_KEY` to checking that `loadAdmins()` returned at least one admin.
|
||||||
|
|
||||||
|
### Verify endpoint
|
||||||
|
|
||||||
|
No changes needed. Already returns `{ valid: true, user: req.user }`. The decoded JWT now naturally includes `name`, so the frontend gets it for free.
|
||||||
|
|
||||||
|
### Auth middleware (`backend/middleware/auth.js`)
|
||||||
|
|
||||||
|
No changes. Already decodes the full JWT payload into `req.user`. Routes that need the admin name can read `req.user.name`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Frontend Auth & Per-Admin Preferences
|
||||||
|
|
||||||
|
### AuthContext changes (`frontend/src/context/AuthContext.jsx`)
|
||||||
|
|
||||||
|
- Add `adminName` to state (alongside `token`, `isAuthenticated`).
|
||||||
|
- `login()`: read `name` from the login response JSON. Store in state and `localStorage` as `adminName`.
|
||||||
|
- `verify()`: read `name` from `response.data.user.name`. Restore `adminName` from that.
|
||||||
|
- `logout()`: clear `adminName` from state and `localStorage`.
|
||||||
|
- Expose `adminName` from the context.
|
||||||
|
|
||||||
|
### Preference namespacing
|
||||||
|
|
||||||
|
A utility function (e.g. in `frontend/src/utils/adminPrefs.js`):
|
||||||
|
- `prefixKey(adminName, key)` → returns `${adminName}:${key}` when `adminName` is set, or plain `key` as fallback.
|
||||||
|
- History page changes from `localStorage.getItem('history-filter')` → `localStorage.getItem(prefixKey(adminName, 'history-filter'))`.
|
||||||
|
|
||||||
|
### One-time migration
|
||||||
|
|
||||||
|
On login, if old un-namespaced keys exist (`history-filter`, `history-show-limit`), copy them to the namespaced versions (`alice:history-filter`, `alice:history-show-limit`) and delete the originals. This preserves existing preferences for the first admin who logs in after the upgrade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: Presence System — "Who Is Watching"
|
||||||
|
|
||||||
|
### WebSocket changes (`backend/utils/websocket-manager.js`)
|
||||||
|
|
||||||
|
- On `auth` message: JWT now carries `name`. Store as `clientInfo.adminName` instead of `clientInfo.userId = decoded.role`.
|
||||||
|
- New message type `page_focus`: clients send `{ type: 'page_focus', page: '/history' }` on navigation. Stored as `clientInfo.currentPage`.
|
||||||
|
- On `page_focus` and on `removeClient` (disconnect): broadcast `presence_update` to all authenticated clients.
|
||||||
|
- Presence payload: `{ type: 'presence_update', admins: [{ name: 'Alice', page: '/history' }, { name: 'Bob', page: '/picker' }] }`.
|
||||||
|
- Unauthenticated clients do not participate in presence.
|
||||||
|
|
||||||
|
### Message flow
|
||||||
|
|
||||||
|
1. Admin connects → sends `{ type: 'auth', token }` → server stores `adminName` from JWT.
|
||||||
|
2. Frontend route change → sends `{ type: 'page_focus', page: '/history' }` → server stores page, broadcasts presence.
|
||||||
|
3. Admin disconnects → server removes them, broadcasts updated presence.
|
||||||
|
4. Each client receives the full presence list and filters locally to admins on the same page.
|
||||||
|
|
||||||
|
### Frontend hook — `usePresence`
|
||||||
|
|
||||||
|
- Connects to the existing WebSocket, sends `page_focus` on route changes (via `useLocation`).
|
||||||
|
- Listens for `presence_update` messages.
|
||||||
|
- Filters to admins on the current page.
|
||||||
|
- Returns `{ viewers }` — array of names, with the current admin's name replaced by `"me"`.
|
||||||
|
|
||||||
|
### Frontend component — `PresenceBar`
|
||||||
|
|
||||||
|
- Renders below the `<nav>`, above page content (in `App.jsx` route layout area).
|
||||||
|
- Only renders when the current admin is authenticated **and at least one other admin is on the same page**. A solo admin sees no presence bar — "me" alone is not useful information.
|
||||||
|
- Small card with caption "who is watching" and a row of name badges (rounded pills).
|
||||||
|
- Styling: subtle, fits the existing indigo/gray theme.
|
||||||
|
- Non-admin visitors see nothing.
|
||||||
|
|
||||||
|
Example when Alice is on `/history` with Bob:
|
||||||
|
```
|
||||||
|
┌─ who is watching ──────────┐
|
||||||
|
│ [me] [Bob] │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed (Summary)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/config/admins.example.json` | New — committed template |
|
||||||
|
| `backend/config/admins.json` | New — gitignored, real keys |
|
||||||
|
| `.gitignore` | Add `backend/config/admins.json` |
|
||||||
|
| `backend/config/load-admins.js` | New — config loader + `findAdminByKey` |
|
||||||
|
| `backend/routes/auth.js` | Use `findAdminByKey`, embed `name` in JWT and response |
|
||||||
|
| `backend/utils/websocket-manager.js` | Store `adminName`, handle `page_focus`, broadcast `presence_update` |
|
||||||
|
| `frontend/src/context/AuthContext.jsx` | Add `adminName` state, persist/restore, expose via context |
|
||||||
|
| `frontend/src/utils/adminPrefs.js` | New — `prefixKey` utility + migration helper |
|
||||||
|
| `frontend/src/pages/History.jsx` | Use namespaced localStorage keys |
|
||||||
|
| `frontend/src/hooks/usePresence.js` | New — WebSocket presence hook |
|
||||||
|
| `frontend/src/components/PresenceBar.jsx` | New — "who is watching" UI component |
|
||||||
|
| `frontend/src/App.jsx` | Render `PresenceBar` in layout |
|
||||||
|
| `docker-compose.yml` | Add `ADMIN_CONFIG_PATH` env var (optional) |
|
||||||
|
| `tests/jest.setup.js` | Update test admin config |
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- **Existing JWTs after deploy:** Tokens issued before this change lack `name`. The `verify` endpoint and `AuthContext` should treat a missing `name` as stale and force re-login (call `logout()`). WebSocket auth should also reject tokens missing `name` (send `auth_error`). Since tokens expire in 24h, this is a brief transition.
|
||||||
|
- **Theme preference:** `ThemeContext` uses the global `theme` localStorage key. Theme stays **shared** (not namespaced per admin) — it's a browser-level display preference, not an admin workflow preference.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- UI-based key management (keys are managed server-side only)
|
||||||
|
- Audit logging / login history (could be added later with a lightweight SQLite table if desired)
|
||||||
|
- Server-side preference storage (per-browser localStorage is sufficient and desirable)
|
||||||
|
- Admin key hashing (keys are compared with strict equality, same as current `ADMIN_KEY`)
|
||||||
@@ -7,6 +7,7 @@ import Logo from './components/Logo';
|
|||||||
import ThemeToggle from './components/ThemeToggle';
|
import ThemeToggle from './components/ThemeToggle';
|
||||||
import InstallPrompt from './components/InstallPrompt';
|
import InstallPrompt from './components/InstallPrompt';
|
||||||
import SafariInstallPrompt from './components/SafariInstallPrompt';
|
import SafariInstallPrompt from './components/SafariInstallPrompt';
|
||||||
|
import PresenceBar from './components/PresenceBar';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Picker from './pages/Picker';
|
import Picker from './pages/Picker';
|
||||||
@@ -156,6 +157,9 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Admin Presence */}
|
||||||
|
<PresenceBar />
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto px-4 py-8 flex-grow">
|
<main className="container mx-auto px-4 py-8 flex-grow">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
41
frontend/src/components/PresenceBar.jsx
Normal file
41
frontend/src/components/PresenceBar.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePresence } from '../hooks/usePresence';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
function PresenceBar() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { viewers } = usePresence();
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
const otherViewers = viewers.filter(v => v !== 'me');
|
||||||
|
if (otherViewers.length === 0) 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 is watching
|
||||||
|
</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}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PresenceBar;
|
||||||
@@ -2,7 +2,7 @@ export const branding = {
|
|||||||
app: {
|
app: {
|
||||||
name: 'HSO Jackbox Game Picker',
|
name: 'HSO Jackbox Game Picker',
|
||||||
shortName: 'Jackbox Game Picker',
|
shortName: 'Jackbox Game Picker',
|
||||||
version: '0.6.2 - Fish Tank Edition',
|
version: '0.6.3 - Fish Tank Edition',
|
||||||
description: 'Spicing up Hyper Spaceout game nights!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { migratePreferences } from '../utils/adminPrefs';
|
||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
||||||
|
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -20,10 +22,17 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const verifyToken = async () => {
|
const verifyToken = async () => {
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/auth/verify', {}, {
|
const response = await axios.post('/api/auth/verify', {}, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
const name = response.data.user?.name;
|
||||||
|
if (name) {
|
||||||
|
setAdminName(name);
|
||||||
|
localStorage.setItem('adminName', name);
|
||||||
|
} else {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification failed:', error);
|
console.error('Token verification failed:', error);
|
||||||
logout();
|
logout();
|
||||||
@@ -38,27 +47,33 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const login = async (key) => {
|
const login = async (key) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/auth/login', { key });
|
const response = await axios.post('/api/auth/login', { key });
|
||||||
const newToken = response.data.token;
|
const { token: newToken, name } = response.data;
|
||||||
localStorage.setItem('adminToken', newToken);
|
localStorage.setItem('adminToken', newToken);
|
||||||
|
localStorage.setItem('adminName', name);
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
|
setAdminName(name);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
migratePreferences(name);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.response?.data?.error || 'Login failed'
|
error: error.response?.data?.error || 'Login failed'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
localStorage.removeItem('adminToken');
|
||||||
|
localStorage.removeItem('adminName');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setAdminName(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
token,
|
token,
|
||||||
|
adminName,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
@@ -67,4 +82,3 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
83
frontend/src/hooks/usePresence.js
Normal file
83
frontend/src/hooks/usePresence.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const WS_RECONNECT_DELAY = 3000;
|
||||||
|
const PING_INTERVAL = 30000;
|
||||||
|
|
||||||
|
export function usePresence() {
|
||||||
|
const { token, adminName, isAuthenticated } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const [viewers, setViewers] = useState([]);
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const pingRef = useRef(null);
|
||||||
|
const reconnectRef = useRef(null);
|
||||||
|
|
||||||
|
const getWsUrl = useCallback(() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/api/sessions/live`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!isAuthenticated || !token) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(getWsUrl());
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (msg.type === 'auth_success') {
|
||||||
|
ws.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
|
||||||
|
|
||||||
|
pingRef.current = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearInterval(pingRef.current);
|
||||||
|
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, token, adminName, location.pathname, getWsUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(reconnectRef.current);
|
||||||
|
clearInterval(pingRef.current);
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.onclose = null;
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return { viewers };
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import { formatLocalDate, isSunday } from '../utils/dateUtils';
|
import { formatLocalDate, isSunday } from '../utils/dateUtils';
|
||||||
|
import { prefixKey } from '../utils/adminPrefs';
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, adminName } = useAuth();
|
||||||
const { error, success } = useToast();
|
const { error, success } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -15,8 +16,8 @@ function History() {
|
|||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [closingSession, setClosingSession] = useState(null);
|
const [closingSession, setClosingSession] = useState(null);
|
||||||
|
|
||||||
const [filter, setFilter] = useState(() => localStorage.getItem('history-filter') || 'default');
|
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
|
||||||
const [limit, setLimit] = useState(() => localStorage.getItem('history-show-limit') || '5');
|
const [limit, setLimit] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-show-limit')) || '5');
|
||||||
|
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
@@ -50,15 +51,24 @@ function History() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminName) {
|
||||||
|
const savedFilter = localStorage.getItem(prefixKey(adminName, 'history-filter'));
|
||||||
|
const savedLimit = localStorage.getItem(prefixKey(adminName, 'history-show-limit'));
|
||||||
|
if (savedFilter) setFilter(savedFilter);
|
||||||
|
if (savedLimit) setLimit(savedLimit);
|
||||||
|
}
|
||||||
|
}, [adminName]);
|
||||||
|
|
||||||
const handleFilterChange = (newFilter) => {
|
const handleFilterChange = (newFilter) => {
|
||||||
setFilter(newFilter);
|
setFilter(newFilter);
|
||||||
localStorage.setItem('history-filter', newFilter);
|
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLimitChange = (newLimit) => {
|
const handleLimitChange = (newLimit) => {
|
||||||
setLimit(newLimit);
|
setLimit(newLimit);
|
||||||
localStorage.setItem('history-show-limit', newLimit);
|
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
frontend/src/utils/adminPrefs.js
Normal file
19
frontend/src/utils/adminPrefs.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const PREF_KEYS = ['history-filter', 'history-show-limit'];
|
||||||
|
|
||||||
|
export function prefixKey(adminName, key) {
|
||||||
|
if (!adminName) return key;
|
||||||
|
return `${adminName}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migratePreferences(adminName) {
|
||||||
|
if (!adminName) return;
|
||||||
|
|
||||||
|
for (const key of PREF_KEYS) {
|
||||||
|
const oldValue = localStorage.getItem(key);
|
||||||
|
const newKey = prefixKey(adminName, key);
|
||||||
|
if (oldValue !== null && localStorage.getItem(newKey) === null) {
|
||||||
|
localStorage.setItem(newKey, oldValue);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
tests/api/named-admins.test.js
Normal file
227
tests/api/named-admins.test.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
describe('load-admins', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'admins-test-'));
|
||||||
|
delete process.env.ADMIN_CONFIG_PATH;
|
||||||
|
delete process.env.ADMIN_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeConfig(admins) {
|
||||||
|
const filePath = path.join(tmpDir, 'admins.json');
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(admins));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loads admins from ADMIN_CONFIG_PATH', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'key-a' },
|
||||||
|
{ name: 'Bob', 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('wrong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
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('wrong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when neither config file nor ADMIN_KEY exists', () => {
|
||||||
|
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('../../backend/config/load-admins');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects duplicate admin names', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'key-a' },
|
||||||
|
{ name: 'Alice', key: 'key-b' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('../../backend/config/load-admins');
|
||||||
|
}).toThrow(/duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects duplicate keys', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'same-key' },
|
||||||
|
{ name: 'Bob', key: 'same-key' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('../../backend/config/load-admins');
|
||||||
|
}).toThrow(/duplicate/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('POST /api/auth/login — named admins', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ADMIN_KEY = 'test-admin-key';
|
||||||
|
process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json';
|
||||||
|
jest.resetModules();
|
||||||
|
({ app } = require('../../backend/server'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login returns admin name in response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ key: 'test-admin-key' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBeDefined();
|
||||||
|
expect(res.body.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verify returns admin name in user object', async () => {
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ key: 'test-admin-key' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/verify')
|
||||||
|
.set('Authorization', `Bearer ${loginRes.body.token}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.name).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid key still returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ key: 'wrong-key' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
describe('WebSocket presence', () => {
|
||||||
|
let server, wsUrl;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
process.env.ADMIN_KEY = 'test-admin-key';
|
||||||
|
process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json';
|
||||||
|
jest.resetModules();
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { WebSocketManager, setWebSocketManager } = require('../../backend/utils/websocket-manager');
|
||||||
|
|
||||||
|
server = http.createServer(app);
|
||||||
|
const wsManager = new WebSocketManager(server);
|
||||||
|
setWebSocketManager(wsManager);
|
||||||
|
|
||||||
|
server.listen(0, () => {
|
||||||
|
const port = server.address().port;
|
||||||
|
wsUrl = `ws://localhost:${port}/api/sessions/live`;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeToken(name) {
|
||||||
|
return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectAndAuth(name) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) }));
|
||||||
|
});
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type === 'auth_success') {
|
||||||
|
resolve(ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(ws, type) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handler = (data) => {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type === type) {
|
||||||
|
ws.off('message', handler);
|
||||||
|
resolve(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.on('message', handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('page_focus triggers presence_update with admin name and page', async () => {
|
||||||
|
const ws1 = await connectAndAuth('Alice');
|
||||||
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(ws2, 'presence_update');
|
||||||
|
|
||||||
|
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
expect(msg.admins).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'Alice', page: '/history' })
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
ws1.close();
|
||||||
|
ws2.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect removes admin from presence', async () => {
|
||||||
|
const ws1 = await connectAndAuth('Alice');
|
||||||
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
|
ws1.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
ws2.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(ws2, 'presence_update');
|
||||||
|
ws1.close();
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const names = msg.admins.map(a => a.name);
|
||||||
|
expect(names).not.toContain('Alice');
|
||||||
|
|
||||||
|
ws2.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -184,6 +184,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [s1.id, s2.id] });
|
.send({ action: 'archive', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -203,6 +204,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'unarchive', ids: [s1.id, s2.id] });
|
.send({ action: 'unarchive', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -219,6 +221,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'delete', ids: [s1.id, s2.id] });
|
.send({ action: 'delete', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -235,6 +238,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [active.id, closed.id] });
|
.send({ action: 'archive', ids: [active.id, closed.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -251,6 +255,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'delete', ids: [active.id] });
|
.send({ action: 'delete', ids: [active.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -260,6 +265,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [] });
|
.send({ action: 'archive', ids: [] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -269,6 +275,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'nuke', ids: [1] });
|
.send({ action: 'nuke', ids: [1] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -278,6 +285,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: 'not-array' });
|
.send({ action: 'archive', ids: 'not-array' });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -289,6 +297,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [s1.id, 9999] });
|
.send({ action: 'archive', ids: [s1.id, 9999] });
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -297,6 +306,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
test('returns 401 without auth', async () => {
|
test('returns 401 without auth', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [1] });
|
.send({ action: 'archive', ids: [1] });
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'New notes here' });
|
.send({ notes: 'New notes here' });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -169,6 +170,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'Replacement' });
|
.send({ notes: 'Replacement' });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -179,6 +181,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put('/api/sessions/99999/notes')
|
.put('/api/sessions/99999/notes')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'test' });
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -189,6 +192,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'test' });
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
@@ -200,6 +204,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
.set('Authorization', 'Bearer invalid-token')
|
.set('Authorization', 'Bearer invalid-token')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'test' });
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const jwt = require('jsonwebtoken');
|
|||||||
const db = require('../../backend/database');
|
const db = require('../../backend/database');
|
||||||
|
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
return jwt.sign({ role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
return jwt.sign({ role: 'admin', name: 'TestAdmin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthHeader() {
|
function getAuthHeader() {
|
||||||
|
|||||||
Reference in New Issue
Block a user