1080 lines
29 KiB
Markdown
1080 lines
29 KiB
Markdown
|
|
# 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 <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**
|
||
|
|
|
||
|
|
```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 (
|
||
|
|
<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 `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 `<PresenceBar />` between the closing `</nav>` tag and the `<main>` tag:
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
</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**
|
||
|
|
|
||
|
|
```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.
|