Files
jackboxpartypack-gamepicker/docs/superpowers/plans/2026-03-23-named-admins.md
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

1080 lines
29 KiB
Markdown

# Named Admins Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the single shared admin key with named admin accounts, per-admin localStorage preferences, and real-time presence badges.
**Architecture:** Config-file-driven admin registry loaded at startup, identity embedded in JWTs, localStorage namespaced by admin name, WebSocket-based per-page presence tracking. Falls back to legacy `ADMIN_KEY` env var.
**Tech Stack:** Node.js/Express backend, React frontend, better-sqlite3, ws (WebSocket), jsonwebtoken, Jest/supertest for tests.
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `backend/config/admins.example.json` | Create | Committed template with placeholder keys |
| `backend/config/load-admins.js` | Create | Load + validate admin config, expose `findAdminByKey()` |
| `backend/routes/auth.js` | Modify | Use `findAdminByKey`, embed `name` in JWT + response |
| `backend/utils/websocket-manager.js` | Modify | Store `adminName`, handle `page_focus`, broadcast `presence_update` |
| `frontend/src/context/AuthContext.jsx` | Modify | Add `adminName` state, persist/restore/expose |
| `frontend/src/utils/adminPrefs.js` | Create | `prefixKey()` utility + one-time migration helper |
| `frontend/src/hooks/usePresence.js` | Create | WebSocket presence hook |
| `frontend/src/components/PresenceBar.jsx` | Create | "Who is watching" UI component |
| `frontend/src/App.jsx` | Modify | Render `PresenceBar` in layout |
| `frontend/src/pages/History.jsx` | Modify | Use namespaced localStorage keys |
| `tests/helpers/test-utils.js` | Modify | Update `getAuthToken` to include `name` |
| `tests/jest.setup.js` | No change | Existing `ADMIN_KEY` env var triggers fallback path — no update needed |
| `tests/api/named-admins.test.js` | Create | Auth tests for multi-key login |
| `.gitignore` | Modify | Add `backend/config/admins.json` |
| `docker-compose.yml` | Modify | Add optional `ADMIN_CONFIG_PATH`, relax `ADMIN_KEY` requirement |
---
### Task 1: Admin Config Loader
**Files:**
- Create: `backend/config/admins.example.json`
- Create: `backend/config/load-admins.js`
- Create: `tests/api/named-admins.test.js`
- Modify: `.gitignore`
- [ ] **Step 1: Add `backend/config/admins.json` to `.gitignore`**
In `.gitignore`, add under the "Local development" section:
```
# Admin config (real keys)
backend/config/admins.json
```
- [ ] **Step 2: Create the example config**
Create `backend/config/admins.example.json`:
```json
[
{ "name": "Alice", "key": "change-me-alice-key" },
{ "name": "Bob", "key": "change-me-bob-key" }
]
```
- [ ] **Step 3: Write the failing test for `load-admins.js`**
Create `tests/api/named-admins.test.js`:
```javascript
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);
});
});
```
- [ ] **Step 4: Run tests to verify they fail**
Run: `cd backend && npm test -- --testPathPattern=named-admins --verbose`
Expected: All 5 tests FAIL (module not found)
- [ ] **Step 5: Implement `load-admins.js`**
Create `backend/config/load-admins.js`:
```javascript
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 };
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd backend && npm test -- --testPathPattern=named-admins --verbose`
Expected: All 5 tests PASS
- [ ] **Step 7: Commit**
```bash
git add .gitignore backend/config/admins.example.json backend/config/load-admins.js tests/api/named-admins.test.js
git commit -m "feat: add admin config loader with multi-key support"
```
---
### Task 2: Update Auth Route
**Files:**
- Modify: `backend/routes/auth.js`
- Modify: `tests/helpers/test-utils.js`
- [ ] **Step 1: Write failing test for named admin login**
Append to `tests/api/named-admins.test.js`:
```javascript
const request = require('supertest');
describe('POST /api/auth/login — named admins', () => {
let app;
beforeAll(() => {
process.env.ADMIN_KEY = 'test-admin-key';
jest.resetModules();
({ app } = require('../../backend/server'));
});
test('login returns admin name in response', async () => {
const res = await request(app)
.post('/api/auth/login')
.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')
.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')
.send({ key: 'wrong-key' });
expect(res.status).toBe(401);
});
});
```
- [ ] **Step 2: Run tests to verify the new tests fail**
Run: `cd backend && npm test -- --testPathPattern=named-admins --verbose`
Expected: New login tests FAIL (response missing `name`)
- [ ] **Step 3: Update `backend/routes/auth.js`**
Replace the entire file content with:
```javascript
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();
router.post('/login', (req, res) => {
const { key } = req.body;
if (!key) {
return res.status(400).json({ error: 'Admin key is required' });
}
const admin = findAdminByKey(key);
if (!admin) {
return res.status(401).json({ error: 'Invalid admin key' });
}
const token = jwt.sign(
{ role: 'admin', name: admin.name, timestamp: Date.now() },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
name: admin.name,
message: 'Authentication successful',
expiresIn: '24h'
});
});
router.post('/verify', authenticateToken, (req, res) => {
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;
```
- [ ] **Step 4: Update `tests/helpers/test-utils.js` — include `name` in test token**
In `tests/helpers/test-utils.js`, change the `getAuthToken` function:
```javascript
function getAuthToken() {
return jwt.sign({ role: 'admin', name: 'TestAdmin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
}
```
- [ ] **Step 5: Run the full test suite to verify nothing is broken**
Run: `cd backend && npm test -- --verbose`
Expected: All tests PASS (named-admins tests + existing tests)
- [ ] **Step 6: Commit**
```bash
git add backend/routes/auth.js tests/helpers/test-utils.js tests/api/named-admins.test.js
git commit -m "feat: auth route uses named admin lookup, embeds name in JWT"
```
---
### Task 3: Frontend AuthContext — Admin Identity
**Files:**
- Modify: `frontend/src/context/AuthContext.jsx`
- [ ] **Step 1: Update `AuthContext.jsx`**
Replace the entire file content with:
```jsx
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
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);
useEffect(() => {
const verifyToken = async () => {
if (token) {
try {
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();
}
}
setLoading(false);
};
verifyToken();
}, [token]);
const login = async (key) => {
try {
const response = await axios.post('/api/auth/login', { key });
const { token: newToken, name } = response.data;
localStorage.setItem('adminToken', newToken);
localStorage.setItem('adminName', name);
setToken(newToken);
setAdminName(name);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
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,
logout
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
```
- [ ] **Step 2: Verify the frontend compiles**
Run: `cd frontend && npx vite build 2>&1 | tail -5`
Expected: Build succeeds with no errors
- [ ] **Step 3: Commit**
```bash
git add frontend/src/context/AuthContext.jsx
git commit -m "feat: AuthContext tracks adminName from login/verify"
```
---
### Task 4: Preference Namespacing Utility + History Page
**Files:**
- Create: `frontend/src/utils/adminPrefs.js`
- Modify: `frontend/src/pages/History.jsx`
- [ ] **Step 1: Create `frontend/src/utils/adminPrefs.js`**
```javascript
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);
}
}
}
```
- [ ] **Step 2: Call migration from AuthContext on login**
In `frontend/src/context/AuthContext.jsx`, add import at top:
```javascript
import { migratePreferences } from '../utils/adminPrefs';
```
In the `login` function, after `setIsAuthenticated(true)` and before `return { success: true }`, add:
```javascript
migratePreferences(name);
```
- [ ] **Step 3: Update `History.jsx` to use namespaced keys**
In `frontend/src/pages/History.jsx`:
Add imports at the top (after existing imports):
```javascript
import { prefixKey } from '../utils/adminPrefs';
```
Change the `useAuth` destructuring to also get `adminName`:
```javascript
const { isAuthenticated, adminName } = useAuth();
```
Change the `filter` useState initializer:
```javascript
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
```
Change the `limit` useState initializer:
```javascript
const [limit, setLimit] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-show-limit')) || '5');
```
Add a `useEffect` to re-read preferences when `adminName` becomes available (handles the edge case where `adminName` is null on first render during token verification):
```javascript
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]);
```
Change `handleFilterChange`:
```javascript
const handleFilterChange = (newFilter) => {
setFilter(newFilter);
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
setSelectedIds(new Set());
};
```
Change `handleLimitChange`:
```javascript
const handleLimitChange = (newLimit) => {
setLimit(newLimit);
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
setSelectedIds(new Set());
};
```
- [ ] **Step 4: Verify the frontend compiles**
Run: `cd frontend && npx vite build 2>&1 | tail -5`
Expected: Build succeeds with no errors
- [ ] **Step 5: Commit**
```bash
git add frontend/src/utils/adminPrefs.js frontend/src/context/AuthContext.jsx frontend/src/pages/History.jsx
git commit -m "feat: per-admin localStorage namespacing with migration"
```
---
### Task 5: WebSocket Presence — Backend
**Files:**
- Modify: `backend/utils/websocket-manager.js`
- [ ] **Step 1: Write failing test for presence messages**
Append to `tests/api/named-admins.test.js`:
```javascript
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';
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' }));
// Wait briefly for page_focus to be processed
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();
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && npm test -- --testPathPattern=named-admins --verbose`
Expected: Presence tests FAIL (`page_focus` not handled, `presence_update` never sent)
- [ ] **Step 3: Update `backend/utils/websocket-manager.js`**
In `handleConnection`, add `currentPage` to the initial `clientInfo`:
```javascript
const clientInfo = {
authenticated: false,
userId: null,
adminName: null,
currentPage: null,
subscribedSessions: new Set(),
lastPing: Date.now()
};
```
In `handleMessage`, add a `page_focus` case to the switch:
```javascript
case 'page_focus':
if (!clientInfo.authenticated) {
this.sendError(ws, 'Not authenticated');
return;
}
clientInfo.currentPage = message.page || null;
this.broadcastPresence();
break;
```
In `authenticateClient`, after `clientInfo.authenticated = true`, change:
```javascript
clientInfo.userId = decoded.role;
```
to:
```javascript
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;
}
```
In `removeClient`, after `this.clients.delete(ws)` and the console.log, add:
```javascript
this.broadcastPresence();
```
Add a new method `broadcastPresence()` to the class:
```javascript
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);
}
});
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && npm test -- --testPathPattern=named-admins --verbose`
Expected: All tests PASS
- [ ] **Step 5: Run the full backend test suite**
Run: `cd backend && npm test -- --verbose`
Expected: All tests PASS
- [ ] **Step 6: Commit**
```bash
git add backend/utils/websocket-manager.js tests/api/named-admins.test.js
git commit -m "feat: WebSocket presence tracking with page_focus and presence_update"
```
---
### Task 6: Frontend Presence Hook
**Files:**
- Create: `frontend/src/hooks/usePresence.js`
- [ ] **Step 1: Create `frontend/src/hooks/usePresence.js`**
```jsx
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 };
}
```
- [ ] **Step 2: Verify the frontend compiles**
Run: `cd frontend && npx vite build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add frontend/src/hooks/usePresence.js
git commit -m "feat: usePresence hook for WebSocket-based page presence"
```
---
### Task 7: PresenceBar Component + App Integration
**Files:**
- Create: `frontend/src/components/PresenceBar.jsx`
- Modify: `frontend/src/App.jsx`
- [ ] **Step 1: Create `frontend/src/components/PresenceBar.jsx`**
```jsx
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;
```
- [ ] **Step 2: Add `PresenceBar` to `App.jsx`**
In `frontend/src/App.jsx`:
Add the import at the top with the other imports:
```javascript
import PresenceBar from './components/PresenceBar';
```
Insert the `<PresenceBar />` between the closing `</nav>` tag and the `<main>` tag:
```jsx
</nav>
{/* Admin Presence */}
<PresenceBar />
{/* Main Content */}
<main className="container mx-auto px-4 py-8 flex-grow">
```
- [ ] **Step 3: Verify the frontend compiles**
Run: `cd frontend && npx vite build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/PresenceBar.jsx frontend/src/App.jsx
git commit -m "feat: PresenceBar component shows who is watching each page"
```
---
### Task 8: Docker & Config Cleanup
**Files:**
- Modify: `docker-compose.yml`
- [ ] **Step 1: Update `docker-compose.yml`**
Change the backend environment section. Replace:
```yaml
- ADMIN_KEY=${ADMIN_KEY:?ADMIN_KEY is required}
```
With:
```yaml
- ADMIN_KEY=${ADMIN_KEY:-}
- ADMIN_CONFIG_PATH=${ADMIN_CONFIG_PATH:-}
```
This makes `ADMIN_KEY` optional (the loader handles the "nothing configured" error). Optionally add a volume mount for the config file (commented out as an example):
Add under the backend `volumes:` section:
```yaml
# - ./backend/config/admins.json:/app/config/admins.json:ro
```
- [ ] **Step 2: Commit**
```bash
git add docker-compose.yml
git commit -m "feat: docker-compose supports optional ADMIN_CONFIG_PATH"
```
---
### Task 9: Manual Smoke Test
No code changes — verify the full flow works end-to-end.
- [ ] **Step 1: Create a local `backend/config/admins.json`**
```json
[
{ "name": "Dev1", "key": "dev-key-1" },
{ "name": "Dev2", "key": "dev-key-2" }
]
```
- [ ] **Step 2: Start the backend**
Run: `cd backend && JWT_SECRET=test-secret node server.js`
Expected: Console shows `[Auth] Loaded 2 admin(s) from .../admins.json`
- [ ] **Step 3: Test login with each key**
```bash
curl -s -X POST http://localhost:5000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"key": "dev-key-1"}' | jq .
```
Expected: Response includes `"name": "Dev1"` and a token
- [ ] **Step 4: Test fallback mode**
Remove `admins.json`, set `ADMIN_KEY=fallback-key`, restart server.
Expected: Console shows fallback message, login works with `fallback-key`, name is `"Admin"`
- [ ] **Step 5: Start frontend and test in two browser tabs**
Open two tabs, log in with different keys. Navigate to the same page.
Expected: Both tabs show "who is watching" bar with "me" and the other admin's name.
- [ ] **Step 6: Test preference isolation**
As Dev1, change History filter to "Archived". Log out, log in as Dev2.
Expected: Dev2 sees the default filter, not Dev1's "Archived" selection.