Add named admins design spec
Covers multi-key admin config, per-admin localStorage preferences, and real-time presence badges via WebSocket. Made-with: Cursor
This commit is contained in:
156
docs/superpowers/specs/2026-03-23-named-admins-design.md
Normal file
156
docs/superpowers/specs/2026-03-23-named-admins-design.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# 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:
|
||||||
|
```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 (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`)
|
||||||
Reference in New Issue
Block a user