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.