# 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 `<nav>`, above page content (in `App.jsx` route layout area).
- Only renders when the current admin is authenticated **and at least one other admin is on the same page**. A solo admin sees no presence bar — "me" alone is not useful information.
- 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 |

## Edge Cases

- **Existing JWTs after deploy:** Tokens issued before this change lack `name`. The `verify` endpoint and `AuthContext` should treat a missing `name` as stale and force re-login (call `logout()`). WebSocket auth should also reject tokens missing `name` (send `auth_error`). Since tokens expire in 24h, this is a brief transition.
- **Theme preference:** `ThemeContext` uses the global `theme` localStorage key. Theme stays **shared** (not namespaced per admin) — it's a browser-level display preference, not an admin workflow preference.

## Out of Scope

- UI-based key management (keys are managed server-side only)
- Audit logging / login history (could be added later with a lightweight SQLite table if desired)
- 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`)
