# 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` (except theme, which stays shared — see Edge Cases). 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: ```json [ { "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 (`history-filter`, `history-show-limit`), copy them to the namespaced versions (`alice:history-filter`, `alice:history-show-limit`) 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 `