Files
jackboxpartypack-gamepicker/docs/superpowers/plans/2026-03-23-named-admins.md
cottongin ac26ac2ac5 Add named admins implementation plan
9 tasks covering config loader, auth changes, frontend identity,
preference namespacing, WebSocket presence, PresenceBar UI, and Docker config.

Made-with: Cursor
2026-03-23 03:41:55 -04:00

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

[
  { "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 — include name in 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.jsx to 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 PresenceBar to App.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.