diff --git a/docs/superpowers/plans/2026-03-23-named-admins.md b/docs/superpowers/plans/2026-03-23-named-admins.md new file mode 100644 index 0000000..2e84f75 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-named-admins.md @@ -0,0 +1,1079 @@ +# 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 {children}; +}; +``` + +- [ ] **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 ( +
+
+
+ + who is watching + +
+ {viewers.map((name, i) => ( + + {name} + + ))} +
+
+
+
+ ); +} + +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 `` between the closing `` tag and the `
` tag: + +```jsx + + + {/* Admin Presence */} + + + {/* Main Content */} +
+``` + +- [ ] **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.