# 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.