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