Compare commits

..

11 Commits

Author SHA1 Message Date
cottongin
3da97a39ad chore: version bump 2026-03-23 10:03:24 -04:00
cottongin
04f66a32cc feat: docker-compose supports optional ADMIN_CONFIG_PATH
ADMIN_KEY is now optional (falls back handled by load-admins.js).
Added ADMIN_CONFIG_PATH env var and commented volume mount example.

Made-with: Cursor
2026-03-23 10:00:46 -04:00
cottongin
95e7402d81 feat: PresenceBar component shows who is watching each page
Made-with: Cursor
2026-03-23 09:57:31 -04:00
cottongin
f0b614e28a feat: usePresence hook for WebSocket-based page presence
Made-with: Cursor
2026-03-23 09:57:22 -04:00
cottongin
242150d54c feat: WebSocket presence tracking with page_focus and presence_update
Made-with: Cursor
2026-03-23 09:55:56 -04:00
cottongin
a4d74baf51 feat: per-admin localStorage namespacing with migration
Made-with: Cursor
2026-03-23 09:42:50 -04:00
cottongin
9f60c6983d feat: auth route uses named admin lookup, embeds name in JWT
- Login/verify use findAdminByKey; JWT and response include admin name
- Verify returns 403 when token lacks name (legacy tokens)
- Test tokens include name for getAuthToken()
- Set Content-Type on supertest JSON bodies (superagent/mime resolution)

Made-with: Cursor
2026-03-23 09:38:35 -04:00
cottongin
fd72c0d7ee feat: add admin config loader with multi-key support
Made-with: Cursor
2026-03-23 03:46:09 -04:00
cottongin
ac26ac2ac5 Add named admins implementation plan
9 tasks covering config loader, auth changes, frontend identity,
preference namespacing, WebSocket presence, PresenceBar UI, and Docker config.

Made-with: Cursor
2026-03-23 03:41:55 -04:00
cottongin
0e5c66b98f Address spec review feedback for named admins design
Clarify WS auth rejection for stale tokens, enumerate all
migrated localStorage keys, and add theme exception note to overview.

Made-with: Cursor
2026-03-23 03:25:15 -04:00
cottongin
86725b6f40 Add named admins design spec
Covers multi-key admin config, per-admin localStorage preferences,
and real-time presence badges via WebSocket.

Made-with: Cursor
2026-03-23 03:19:03 -04:00
19 changed files with 1785 additions and 31 deletions

3
.gitignore vendored
View File

@@ -39,6 +39,9 @@ Thumbs.db
.local/
.old-chrome-extension/
# Admin config (real keys)
backend/config/admins.json
# Cursor
.cursor/
chat-summaries/

View File

@@ -0,0 +1,4 @@
[
{ "name": "Alice", "key": "change-me-alice-key" },
{ "name": "Bob", "key": "change-me-bob-key" }
]

View 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 };

View File

@@ -1,15 +1,10 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
const { findAdminByKey } = require('../config/load-admins');
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) => {
const { key } = req.body;
@@ -17,31 +12,34 @@ router.post('/login', (req, res) => {
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' });
}
// Generate JWT token
const token = jwt.sign(
{ role: 'admin', timestamp: Date.now() },
{ role: 'admin', name: admin.name, timestamp: Date.now() },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
res.json({
token,
name: admin.name,
message: 'Authentication successful',
expiresIn: '24h'
});
});
// Verify token validity
router.post('/verify', authenticateToken, (req, res) => {
res.json({
valid: true,
user: req.user
if (!req.user.name) {
return res.status(403).json({ error: 'Token missing admin identity, please re-login' });
}
res.json({
valid: true,
user: req.user
});
});
module.exports = router;

View File

@@ -32,6 +32,8 @@ class WebSocketManager {
const clientInfo = {
authenticated: false,
userId: null,
adminName: null,
currentPage: null,
subscribedSessions: new Set(),
lastPing: Date.now()
};
@@ -96,6 +98,15 @@ class WebSocketManager {
clientInfo.lastPing = Date.now();
this.send(ws, { type: 'pong' });
break;
case 'page_focus':
if (!clientInfo.authenticated) {
this.sendError(ws, 'Not authenticated');
return;
}
clientInfo.currentPage = message.page || null;
this.broadcastPresence();
break;
default:
this.sendError(ws, `Unknown message type: ${message.type}`);
@@ -117,7 +128,13 @@ class WebSocketManager {
if (clientInfo) {
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, {
type: 'auth_success',
@@ -283,9 +300,31 @@ class WebSocketManager {
this.clients.delete(ws);
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
*/

View File

@@ -10,11 +10,13 @@ services:
- NODE_ENV=production
- DB_PATH=/app/data/jackbox.db
- 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
volumes:
- jackbox-data:/app/data
- ./games-list.csv:/app/games-list.csv:ro
# - ./backend/config/admins.json:/app/config/admins.json:ro
ports:
- "5000:5000"
networks:

File diff suppressed because it is too large Load Diff

View 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`)

View File

@@ -7,6 +7,7 @@ import Logo from './components/Logo';
import ThemeToggle from './components/ThemeToggle';
import InstallPrompt from './components/InstallPrompt';
import SafariInstallPrompt from './components/SafariInstallPrompt';
import PresenceBar from './components/PresenceBar';
import Home from './pages/Home';
import Login from './pages/Login';
import Picker from './pages/Picker';
@@ -156,6 +157,9 @@ function App() {
</div>
</nav>
{/* Admin Presence */}
<PresenceBar />
{/* Main Content */}
<main className="container mx-auto px-4 py-8 flex-grow">
<Routes>

View 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;

View File

@@ -2,7 +2,7 @@ export const branding = {
app: {
name: 'HSO 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!',
},
meta: {

View File

@@ -1,5 +1,6 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
import { migratePreferences } from '../utils/adminPrefs';
const AuthContext = createContext();
@@ -13,6 +14,7 @@ export const useAuth = () => {
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem('adminToken'));
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
@@ -20,10 +22,17 @@ export const AuthProvider = ({ children }) => {
const verifyToken = async () => {
if (token) {
try {
await axios.post('/api/auth/verify', {}, {
const response = await axios.post('/api/auth/verify', {}, {
headers: { Authorization: `Bearer ${token}` }
});
setIsAuthenticated(true);
const name = response.data.user?.name;
if (name) {
setAdminName(name);
localStorage.setItem('adminName', name);
} else {
logout();
}
} catch (error) {
console.error('Token verification failed:', error);
logout();
@@ -38,27 +47,33 @@ export const AuthProvider = ({ children }) => {
const login = async (key) => {
try {
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('adminName', name);
setToken(newToken);
setAdminName(name);
setIsAuthenticated(true);
migratePreferences(name);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data?.error || 'Login failed'
return {
success: false,
error: error.response?.data?.error || 'Login failed'
};
}
};
const logout = () => {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminName');
setToken(null);
setAdminName(null);
setIsAuthenticated(false);
};
const value = {
token,
adminName,
isAuthenticated,
loading,
login,
@@ -67,4 +82,3 @@ export const AuthProvider = ({ children }) => {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View 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 };
}

View File

@@ -4,9 +4,10 @@ import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast';
import api from '../api/axios';
import { formatLocalDate, isSunday } from '../utils/dateUtils';
import { prefixKey } from '../utils/adminPrefs';
function History() {
const { isAuthenticated } = useAuth();
const { isAuthenticated, adminName } = useAuth();
const { error, success } = useToast();
const navigate = useNavigate();
@@ -15,8 +16,8 @@ function History() {
const [totalCount, setTotalCount] = useState(0);
const [closingSession, setClosingSession] = useState(null);
const [filter, setFilter] = useState(() => localStorage.getItem('history-filter') || 'default');
const [limit, setLimit] = useState(() => localStorage.getItem('history-show-limit') || '5');
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
const [limit, setLimit] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-show-limit')) || '5');
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
@@ -50,15 +51,24 @@ function History() {
return () => clearInterval(interval);
}, [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) => {
setFilter(newFilter);
localStorage.setItem('history-filter', newFilter);
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
setSelectedIds(new Set());
};
const handleLimitChange = (newLimit) => {
setLimit(newLimit);
localStorage.setItem('history-show-limit', newLimit);
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
setSelectedIds(new Set());
};

View 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);
}
}
}

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

View File

@@ -184,6 +184,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'archive', ids: [s1.id, s2.id] });
expect(res.status).toBe(200);
@@ -203,6 +204,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'unarchive', ids: [s1.id, s2.id] });
expect(res.status).toBe(200);
@@ -219,6 +221,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'delete', ids: [s1.id, s2.id] });
expect(res.status).toBe(200);
@@ -235,6 +238,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'archive', ids: [active.id, closed.id] });
expect(res.status).toBe(400);
@@ -251,6 +255,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'delete', ids: [active.id] });
expect(res.status).toBe(400);
@@ -260,6 +265,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'archive', ids: [] });
expect(res.status).toBe(400);
@@ -269,6 +275,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'nuke', ids: [1] });
expect(res.status).toBe(400);
@@ -278,6 +285,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'archive', ids: 'not-array' });
expect(res.status).toBe(400);
@@ -289,6 +297,7 @@ describe('POST /api/sessions/bulk', () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ action: 'archive', ids: [s1.id, 9999] });
expect(res.status).toBe(404);
@@ -297,6 +306,7 @@ describe('POST /api/sessions/bulk', () => {
test('returns 401 without auth', async () => {
const res = await request(app)
.post('/api/sessions/bulk')
.set('Content-Type', 'application/json')
.send({ action: 'archive', ids: [1] });
expect(res.status).toBe(401);

View File

@@ -157,6 +157,7 @@ describe('PUT /api/sessions/:id/notes', () => {
const res = await request(app)
.put(`/api/sessions/${session.id}/notes`)
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ notes: 'New notes here' });
expect(res.status).toBe(200);
@@ -169,6 +170,7 @@ describe('PUT /api/sessions/:id/notes', () => {
const res = await request(app)
.put(`/api/sessions/${session.id}/notes`)
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ notes: 'Replacement' });
expect(res.status).toBe(200);
@@ -179,6 +181,7 @@ describe('PUT /api/sessions/:id/notes', () => {
const res = await request(app)
.put('/api/sessions/99999/notes')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ notes: 'test' });
expect(res.status).toBe(404);
@@ -189,6 +192,7 @@ describe('PUT /api/sessions/:id/notes', () => {
const res = await request(app)
.put(`/api/sessions/${session.id}/notes`)
.set('Content-Type', 'application/json')
.send({ notes: 'test' });
expect(res.status).toBe(401);
@@ -200,6 +204,7 @@ describe('PUT /api/sessions/:id/notes', () => {
const res = await request(app)
.put(`/api/sessions/${session.id}/notes`)
.set('Authorization', 'Bearer invalid-token')
.set('Content-Type', 'application/json')
.send({ notes: 'test' });
expect(res.status).toBe(403);

View File

@@ -2,7 +2,7 @@ const jwt = require('jsonwebtoken');
const db = require('../../backend/database');
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() {