Covers multi-key admin config, per-admin localStorage preferences, and real-time presence badges via WebSocket. Made-with: Cursor
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
- Named admins with multiple keys — each admin has a name and a unique key, defined in a server-side config file.
- Per-admin preferences — UI preferences (saved filter view, show limit, etc.) are linked to the admin who set them via namespaced
localStorage. - 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:
- Read path from
ADMIN_CONFIG_PATHenv var, or default tobackend/config/admins.json. - If the file exists: parse and validate (must be an array of
{ name, key }, no duplicate names or keys). - If the file does not exist: fall back to
ADMIN_KEYenv var →[{ name: "Admin", key: ADMIN_KEY }]. - If neither exists: throw at startup (fail-fast, same as current behavior).
Exports:
findAdminByKey(key)— returns{ name }ornull.
Section 2: Authentication Changes (Backend)
Login endpoint (backend/routes/auth.js)
- Replace single
ADMIN_KEYcomparison withfindAdminByKey(key). - On match, embed the admin name in the JWT payload:
{ role: 'admin', name: 'Alice', timestamp: Date.now() }. - Add
nameto the login response JSON:{ token, name, message, expiresIn }. - Startup guard changes from checking
ADMIN_KEYto checking thatloadAdmins()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
adminNameto state (alongsidetoken,isAuthenticated). login(): readnamefrom the login response JSON. Store in state andlocalStorageasadminName.verify(): readnamefromresponse.data.user.name. RestoreadminNamefrom that.logout(): clearadminNamefrom state andlocalStorage.- Expose
adminNamefrom the context.
Preference namespacing
A utility function (e.g. in frontend/src/utils/adminPrefs.js):
prefixKey(adminName, key)→ returns${adminName}:${key}whenadminNameis set, or plainkeyas 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
authmessage: JWT now carriesname. Store asclientInfo.adminNameinstead ofclientInfo.userId = decoded.role. - New message type
page_focus: clients send{ type: 'page_focus', page: '/history' }on navigation. Stored asclientInfo.currentPage. - On
page_focusand onremoveClient(disconnect): broadcastpresence_updateto 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
- Admin connects → sends
{ type: 'auth', token }→ server storesadminNamefrom JWT. - Frontend route change → sends
{ type: 'page_focus', page: '/history' }→ server stores page, broadcasts presence. - Admin disconnects → server removes them, broadcasts updated presence.
- 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_focuson route changes (viauseLocation). - Listens for
presence_updatemessages. - 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 (inApp.jsxroute 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)