# 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