Files
jackboxpartypack-gamepicker/docs/superpowers/specs/2026-03-23-named-admins-design.md
cottongin 86725b6f40 Add named admins design spec
Covers multi-key admin config, per-admin localStorage preferences,
and real-time presence badges via WebSocket.

Made-with: Cursor
2026-03-23 03:19:03 -04:00

7.2 KiB

Named Admins Design

Date: 2026-03-23 Status: Approved

Overview

Replace the single shared ADMIN_KEY with named admin accounts, each with their own key. Per-admin preferences are stored in namespaced localStorage. A real-time "who is watching" presence bar shows which admins are on the same page.

Requirements

  1. Named admins with multiple keys — each admin has a name and a unique key, defined in a server-side config file.
  2. Per-admin preferences — UI preferences (saved filter view, show limit, etc.) are linked to the admin who set them via namespaced localStorage.
  3. Presence badge — a "who is watching" card in the page header shows which admins are viewing the same page. The current admin sees "me" for themselves, full names for others.

Approach

Identity-in-JWT with a config file. No new database tables. Preferences stay in localStorage (per-browser, which is desirable). Presence piggybacks on the existing WebSocket infrastructure. Falls back to the old ADMIN_KEY env var for backward compatibility.


Section 1: Admin Configuration

Config file

  • backend/config/admins.example.json — committed to repo, shows the expected shape:
    [
      { "name": "Alice", "key": "change-me-alice-key" },
      { "name": "Bob", "key": "change-me-bob-key" }
    ]
    
  • backend/config/admins.json — gitignored, contains real keys.

Loader module

New file: backend/config/load-admins.js

Startup behavior:

  1. Read path from ADMIN_CONFIG_PATH env var, or default to backend/config/admins.json.
  2. If the file exists: parse and validate (must be an array of { name, key }, no duplicate names or keys).
  3. If the file does not exist: fall back to ADMIN_KEY env var → [{ name: "Admin", key: ADMIN_KEY }].
  4. If neither exists: throw at startup (fail-fast, same as current behavior).

Exports:

  • findAdminByKey(key) — returns { name } or null.

Section 2: Authentication Changes (Backend)

Login endpoint (backend/routes/auth.js)

  • Replace single ADMIN_KEY comparison with findAdminByKey(key).
  • On match, embed the admin name in the JWT payload: { role: 'admin', name: 'Alice', timestamp: Date.now() }.
  • Add name to the login response JSON: { token, name, message, expiresIn }.
  • Startup guard changes from checking ADMIN_KEY to checking that loadAdmins() returned at least one admin.

Verify endpoint

No changes needed. Already returns { valid: true, user: req.user }. The decoded JWT now naturally includes name, so the frontend gets it for free.

Auth middleware (backend/middleware/auth.js)

No changes. Already decodes the full JWT payload into req.user. Routes that need the admin name can read req.user.name.


Section 3: Frontend Auth & Per-Admin Preferences

AuthContext changes (frontend/src/context/AuthContext.jsx)

  • Add adminName to state (alongside token, isAuthenticated).
  • login(): read name from the login response JSON. Store in state and localStorage as adminName.
  • verify(): read name from response.data.user.name. Restore adminName from that.
  • logout(): clear adminName from state and localStorage.
  • Expose adminName from the context.

Preference namespacing

A utility function (e.g. in frontend/src/utils/adminPrefs.js):

  • prefixKey(adminName, key) → returns ${adminName}:${key} when adminName is set, or plain key as fallback.
  • History page changes from localStorage.getItem('history-filter')localStorage.getItem(prefixKey(adminName, 'history-filter')).

One-time migration

On login, if old un-namespaced keys exist (e.g. history-filter), copy them to the namespaced version (alice:history-filter) and delete the originals. This preserves existing preferences for the first admin who logs in after the upgrade.


Section 4: Presence System — "Who Is Watching"

WebSocket changes (backend/utils/websocket-manager.js)

  • On auth message: JWT now carries name. Store as clientInfo.adminName instead of clientInfo.userId = decoded.role.
  • New message type page_focus: clients send { type: 'page_focus', page: '/history' } on navigation. Stored as clientInfo.currentPage.
  • On page_focus and on removeClient (disconnect): broadcast presence_update to all authenticated clients.
  • Presence payload: { type: 'presence_update', admins: [{ name: 'Alice', page: '/history' }, { name: 'Bob', page: '/picker' }] }.
  • Unauthenticated clients do not participate in presence.

Message flow

  1. Admin connects → sends { type: 'auth', token } → server stores adminName from JWT.
  2. Frontend route change → sends { type: 'page_focus', page: '/history' } → server stores page, broadcasts presence.
  3. Admin disconnects → server removes them, broadcasts updated presence.
  4. Each client receives the full presence list and filters locally to admins on the same page.

Frontend hook — usePresence

  • Connects to the existing WebSocket, sends page_focus on route changes (via useLocation).
  • Listens for presence_update messages.
  • Filters to admins on the current page.
  • Returns { viewers } — array of names, with the current admin's name replaced by "me".

Frontend component — PresenceBar

  • Renders below the <nav>, above page content (in App.jsx route layout area).
  • Only renders when the current admin is authenticated and there are viewers.
  • Small card with caption "who is watching" and a row of name badges (rounded pills).
  • Styling: subtle, fits the existing indigo/gray theme.
  • Non-admin visitors see nothing.

Example when Alice is on /history with Bob:

┌─ who is watching ──────────┐
│  [me]  [Bob]               │
└────────────────────────────┘

Files Changed (Summary)

File Change
backend/config/admins.example.json New — committed template
backend/config/admins.json New — gitignored, real keys
.gitignore Add backend/config/admins.json
backend/config/load-admins.js New — config loader + findAdminByKey
backend/routes/auth.js Use findAdminByKey, embed name in JWT and response
backend/utils/websocket-manager.js Store adminName, handle page_focus, broadcast presence_update
frontend/src/context/AuthContext.jsx Add adminName state, persist/restore, expose via context
frontend/src/utils/adminPrefs.js New — prefixKey utility + migration helper
frontend/src/pages/History.jsx Use namespaced localStorage keys
frontend/src/hooks/usePresence.js New — WebSocket presence hook
frontend/src/components/PresenceBar.jsx New — "who is watching" UI component
frontend/src/App.jsx Render PresenceBar in layout
docker-compose.yml Add ADMIN_CONFIG_PATH env var (optional)
tests/jest.setup.js Update test admin config

Out of Scope

  • UI-based key management (keys are managed server-side only)
  • Audit logging / login history (clean upgrade path via Approach 3 if desired later)
  • Server-side preference storage (per-browser localStorage is sufficient and desirable)
  • Admin key hashing (keys are compared with strict equality, same as current ADMIN_KEY)