9 tasks covering config loader, auth changes, frontend identity, preference namespacing, WebSocket presence, PresenceBar UI, and Docker config. Made-with: Cursor
29 KiB
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.jsonto.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:
[
{ "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:
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:
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
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:
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:
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— includenamein test token
In tests/helpers/test-utils.js, change the getAuthToken function:
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
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:
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
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
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:
import { migratePreferences } from '../utils/adminPrefs';
In the login function, after setIsAuthenticated(true) and before return { success: true }, add:
migratePreferences(name);
- Step 3: Update
History.jsxto use namespaced keys
In frontend/src/pages/History.jsx:
Add imports at the top (after existing imports):
import { prefixKey } from '../utils/adminPrefs';
Change the useAuth destructuring to also get adminName:
const { isAuthenticated, adminName } = useAuth();
Change the filter useState initializer:
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
Change the limit useState initializer:
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):
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:
const handleFilterChange = (newFilter) => {
setFilter(newFilter);
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
setSelectedIds(new Set());
};
Change handleLimitChange:
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
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:
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:
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:
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:
clientInfo.userId = decoded.role;
to:
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:
this.broadcastPresence();
Add a new method broadcastPresence() to the class:
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
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
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
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
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
PresenceBartoApp.jsx
In frontend/src/App.jsx:
Add the import at the top with the other imports:
import PresenceBar from './components/PresenceBar';
Insert the <PresenceBar /> between the closing </nav> tag and the <main> tag:
</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
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:
- ADMIN_KEY=${ADMIN_KEY:?ADMIN_KEY is required}
With:
- 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:
# - ./backend/config/admins.json:/app/config/admins.json:ro
- Step 2: Commit
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
[
{ "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
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.