# Frontend Admin — Authentication & Authorization Architecture Review

**Date:** 2026-07-31
**Scope:** `frontend-admin/` (the Super Admin console), plus the backend surfaces it
depends on, read only to establish where the real security boundary sits.
**Status:** **Superseded — describes the state *before* the consistency pass.**
This document was written as a read-only review; the consistency-and-hardening pass that
followed it acted on §13.6 and the Weaknesses list, so several findings below no longer
describe the code. Read it as the "before" picture and see
[frontend_admin_auth_consistency_pass.md](frontend_admin_auth_consistency_pass.md) for
what changed and what was deliberately left alone.

> Note on this file's location: the repo convention is that `docs/` is organised into
> topic folders and new documents are not dropped at the root. This file was placed at
> `docs/frontend_admin_auth_review.md` because that exact path was requested.

---

## 0. File map

The entire auth/authz surface of the admin console is five files, plus per-page usage.

| File | Role |
| --- | --- |
| [main.jsx](frontend-admin/src/main.jsx) | Mounts `<BrowserRouter><App/></BrowserRouter>`. No auth logic. |
| [App.jsx](frontend-admin/src/App.jsx) | Wraps everything in `AdminAuthProvider`; defines `Protected` and the route table. |
| [context/AdminAuthContext.jsx](frontend-admin/src/context/AdminAuthContext.jsx) | The one provider. Owns `admin` + `loading`, exposes `login`/`logout`/`refresh`/`hasPermission`/`hasRole`/`isSuperAdmin`/`isAuthed`. |
| [auth/roles.js](frontend-admin/src/auth/roles.js) | Pure functions that interpret the role field of the admin payload. No React. |
| [api.js](frontend-admin/src/api.js) | Axios instance, `tokenStore` (localStorage), request/response interceptors, and the whole `adminApi` call surface. |
| [components/AdminLayout.jsx](frontend-admin/src/components/AdminLayout.jsx) | Shell + sidebar; the only place navigation visibility is decided. |

Working-tree state at time of review: `auth/roles.js` is **untracked** and
`AdminAuthContext.jsx`, `AdminLayout.jsx`, `Admins.jsx`, `OperationsCenter.jsx`,
`ResearchWorkbench.jsx` are **modified but uncommitted**. The changes centralise role
checks that were previously inlined per page (see §13.1).

---

## 1. Authentication flow

### 1.1 Cold boot / session restoration

```
index.html → main.jsx
    createRoot(#admin-root)
      └── <React.StrictMode>
            └── <BrowserRouter>
                  └── <App>
                        └── <AdminAuthProvider>        ← state: admin=null, loading=true
                              └── <Routes>
```

`AdminAuthProvider` runs `refresh()` in a mount effect
([AdminAuthContext.jsx:28](frontend-admin/src/context/AdminAuthContext.jsx#L28)):

1. `tokenStore.get()` reads `localStorage['admin_access_token']`.
   - **Absent** → `setAdmin(null)`, `setLoading(false)`. Done; the user is anonymous.
   - **Present** → continue.
2. `adminApi.me()` → `GET /api/admin/auth/me`. The request interceptor
   ([api.js:18](frontend-admin/src/api.js#L18)) attaches `Authorization: Bearer <token>`.
3. Success → `setAdmin(me)` (the `/me` payload, §2).
4. Any failure → `setAdmin(null)` **and** `tokenStore.clear()` (both tokens removed).
   Note this is a blanket `catch`: a network error or a 500 is treated identically to a
   401 and drops the stored session.
5. `finally` → `setLoading(false)`.

Meanwhile `Protected` ([App.jsx:33](frontend-admin/src/App.jsx#L33)) renders
`<div className="adm-boot">Loading…</div>` for the whole app while `loading` is true, so
no page mounts before the identity is known. There is no flash of protected content.

Because of `React.StrictMode`, the effect runs twice in dev, producing two `/me` calls on
boot. Harmless — both are idempotent GETs.

### 1.2 Login

[Login.jsx](frontend-admin/src/pages/Login.jsx) is a controlled form. `onSubmit`:

1. `login(email.trim(), password)` → context
   ([AdminAuthContext.jsx:30](frontend-admin/src/context/AdminAuthContext.jsx#L30)).
2. `adminApi.login()` → `POST /api/admin/auth/login` `{email, password}`.
3. On success: `tokenStore.set(data.access_token)`,
   `tokenStore.refresh.set(data.refresh_token)`, `setAdmin(data.admin)`.
   Note the state is seeded from the **login** payload, which is a different (smaller)
   shape than `/me` — see §2.3.
4. Login page navigates to `loc.state?.from?.pathname || '/dashboard'` with `replace`.
   `state.from` is the location captured by `Protected` at the time of the redirect, so
   deep links survive a login round-trip.
5. On failure: the axios error is unwrapped as
   `ex.response?.data?.detail` (string) or `detail.message`, else `'Login failed'`.
   Backend returns a bare string `"Invalid credentials"` for 401, so the string branch is
   the live one.

`/login` itself is **not** guarded in the other direction: an already-authenticated admin
who navigates to `/login` sees the form again rather than being bounced to `/dashboard`.

### 1.3 Logout

[AdminAuthContext.jsx:38](frontend-admin/src/context/AdminAuthContext.jsx#L38):

1. Read the refresh token; if present, `POST /api/admin/auth/logout {refresh_token}`.
   The call is wrapped in `try/catch {}` — a failed server-side revocation is silently
   ignored and the client still logs out.
2. `tokenStore.clear()` — removes both localStorage keys.
3. `setAdmin(null)`.

There is no explicit navigation. `isAuthed` flips false, `Protected` re-renders and
`<Navigate to="/login" replace state={{from: loc}} />` fires. Side effect: `state.from`
is the page the admin was on when they signed out, so the next login returns them there.

The only logout trigger in the UI is the "Sign out" button in the sidebar footer
([AdminLayout.jsx:130](frontend-admin/src/components/AdminLayout.jsx#L130)).

### 1.4 Token refresh

Implemented entirely in the axios response interceptor
([api.js:37-53](frontend-admin/src/api.js#L37-L53)):

```
response 401
  && !config.__retried
  && !config.url.includes('/auth/refresh')
      → tryRefresh()
          ├─ no refresh token in storage → null
          ├─ single-flight: if a refresh is already in flight, await that promise
          ├─ POST /api/admin/auth/refresh {refresh_token}   (bare `axios`, not `api`)
          │     success → tokenStore.set(new access token) → return it
          │     failure → tokenStore.clear() → return null
      → token   → replay the original request with the new Authorization header
      → null    → window.location.href = '/login'   (hard navigation)
  then always: Promise.reject(error)
```

Characteristics:

- **Single-flight.** `_refreshing` ensures a burst of concurrent 401s produces one
  refresh call; all callers await the same promise.
- **One retry per request.** `config.__retried` is set before retrying, so a request can
  never loop.
- **The refresh call bypasses the interceptors** (it uses the bare `axios` default
  instance), so it carries no `Authorization` header — correct, since the endpoint
  authenticates from the body. The `url.includes('/auth/refresh')` guard is therefore
  redundant belt-and-braces.
- **Hard redirect on failure.** `window.location.href = '/login'` performs a full page
  load, discarding React state. This is the only place the app force-navigates, and it
  bypasses the router entirely — `state.from` is lost, so the deep link is not preserved
  on session expiry (unlike the `Protected` path).
- **The rejection still propagates.** Even after a redirect is scheduled, the promise is
  rejected, so calling pages briefly run their own error handlers.
- **The refresh token is not rotated.** Only the access token is replaced.

### 1.5 Token storage and expiry

| | Value |
| --- | --- |
| Access token key | `localStorage['admin_access_token']` |
| Refresh token key | `localStorage['admin_refresh_token']` |
| Access lifetime | 60 minutes (`admin_access_token_expire_minutes`, backend `config.py:48`) |
| Refresh lifetime | 8 hours (`admin_refresh_token_expire_hours`, backend `config.py:49`) |
| Client-side expiry check | **None.** The JWT is never decoded in the frontend. |
| `expires_in` from the login response | **Ignored.** |

Expiry is discovered reactively: a request 401s, the interceptor refreshes, and after
8 hours the refresh itself fails and the admin is bounced to `/login`. There is no
proactive refresh timer, no idle timeout, and no countdown UI.

Consequences of localStorage as the store: tokens are readable by any script on the
origin (no `httpOnly` cookie), they persist across browser restarts, and they are shared
across tabs. There is **no `storage` event listener**, so logging out in one tab leaves
other open tabs holding a stale `admin` object in memory until their next API call 401s.

### 1.6 End-to-end: login → rendered page

```
/login  →  submit  →  POST /admin/auth/login
                        ↓ 200 {access_token, refresh_token, admin{…}}
                      localStorage ← both tokens
                      context.admin ← data.admin        (login shape)
                        ↓
                      navigate('/dashboard', {replace})
                        ↓
                      <Protected>  loading=false, isAuthed=true → render children
                        ↓
                      <AdminLayout>
                          nav = NAV.filter(item => !item.superOnly || isSuperAdmin)
                          <Outlet/>
                        ↓
                      <Dashboard>
                          GET /api/admin/dashboard   (interceptor adds Bearer)
                          backend: require_permission("dashboard.view")
                          tiles/quick-actions rendered conditionally on hasPermission(…)
```

On a page reload the first two steps are replaced by `refresh()` → `GET /admin/auth/me`,
and `context.admin` gets the richer `/me` shape.

---

## 2. Authentication contract

### 2.1 `GET /api/admin/auth/me` (backend `admin/routes/auth.py:154`)

```json
{
  "id": 1,
  "email": "admin@example.com",
  "full_name": "Nitin Surani",
  "role": "super_admin",
  "role_name": "Super Admin",
  "is_active": true,
  "last_login_at": "2026-07-31T09:12:44",
  "permissions": ["*"]
}
```

### 2.2 `POST /api/admin/auth/login` (backend `admin/routes/auth.py:41`)

```json
{
  "access_token": "…",
  "refresh_token": "…",
  "token_type": "bearer",
  "expires_in": 3600,
  "admin": {
    "id": 1,
    "email": "admin@example.com",
    "full_name": "Nitin Surani",
    "role": "super_admin",
    "permissions": ["*"]
  }
}
```

### 2.3 The two shapes are not the same

`login.admin` **omits** `role_name`, `is_active` and `last_login_at`. Since
`context.admin` is set directly from `data.admin` on login and from the `/me` body on
reload, the in-memory admin object has different fields depending on how the session
started. `auth/roles.js` documents this explicitly and `roleLabel()` handles it with a
fallback to the role code.

`role` is always a **plain string** in both payloads — never a nested `{code, name}`
object. `auth/roles.js` exists specifically to make that the only interpretation in the
codebase (§13.1 records why).

### 2.4 Field-by-field consumption

| Field | Where consumed | Verdict |
| --- | --- | --- |
| `id` | [Admins.jsx:102](frontend-admin/src/pages/Admins.jsx#L102) — `me?.id === row.id` to mark "· you" and suppress self-management buttons | Used |
| `email` | Nowhere for the authenticated admin (the email shown in `Admins.jsx` modals belongs to the *listed* row) | **Ignored** |
| `full_name` | Nowhere. `AdminLayout` no longer reads `admin` at all; the sidebar shows a static "Super Admin" sub-title | **Ignored** |
| `role` | Only via `auth/roles.js` (`roleOf` → `hasRole` → `isSuperAdmin`), consumed through the context | Used |
| `role_name` | Only by `roleLabel()`, which **is never called** anywhere in the app | **Effectively ignored** (dead export) |
| `is_active` | Nowhere. A deactivated admin is stopped server-side (`get_current_admin` 403s) | **Ignored** |
| `last_login_at` | Nowhere for the authenticated admin (the Admins table shows each *row's* `last_login_at`) | **Ignored** |
| `permissions` | `hasPermission()` in the context — the only reader | Used |
| `access_token` / `refresh_token` | `tokenStore` | Used |
| `token_type` | Nowhere; `"Bearer "` is hard-coded in the interceptor | **Ignored** |
| `expires_in` | Nowhere | **Ignored** |

Effectively three of the eight identity fields are load-bearing: `id`, `role`,
`permissions`.

---

## 3. `AdminAuthContext`

Single file, single context, no reducer, no persistence beyond the token store.

**State**

| Name | Type | Initial | Mutated by |
| --- | --- | --- | --- |
| `admin` | object \| null | `null` | `refresh()`, `login()`, `logout()` |
| `loading` | boolean | `true` | `refresh()` only (set false in its `finally`) |

`loading` is a *boot* flag, not a request flag. It is set true exactly once (initial
state) and never returns to true — `login()` and `logout()` do not touch it, and a manual
`refresh()` call after boot leaves it false.

**Exposed value** ([AdminAuthContext.jsx:61-70](frontend-admin/src/context/AdminAuthContext.jsx#L61-L70))

| Key | Kind | Notes |
| --- | --- | --- |
| `admin` | raw payload | The whole object, un-normalised |
| `loading` | boolean | Boot-only, as above |
| `login(email, password)` | async action | Returns the admin object; throws the axios error on failure |
| `logout()` | async action | Never throws |
| `refresh()` | async action | `useCallback`, stable identity; re-reads `/me` |
| `hasPermission(code)` | predicate | §9 |
| `hasRole(code)` | predicate | Delegates to `auth/roles.js` |
| `isSuperAdmin` | **boolean, not a function** | Derived per render from `admin` |
| `isAuthed` | derived boolean | `!!admin` — presence of an admin object, *not* presence of a token |

`isSuperAdmin` being a value while `hasRole` is a function is deliberate and commented
in-file: "it is state, not an action." It is the one asymmetry a consumer must know about.

**Derived values** are computed inline on every render; the provider value object is
**not** memoised. In practice the provider only re-renders when `admin` or `loading`
changes, so the missing `useMemo` costs nothing today, but every consumer is subscribed to
object identity rather than to the specific field it reads.

**Initialization** — mount effect → `refresh()` (§1.1). There is no token-validity
pre-check; the server is the arbiter.

**Logout behaviour** — best-effort server revocation, then unconditional local clear (§1.3).

**What other components are expected to use:** `useAdminAuth()` and nothing else. There is
no direct import of `tokenStore` outside `api.js`, and no component imports `auth/roles.js`
for an authorization decision — the one import of it outside the context
([Admins.jsx:4](frontend-admin/src/pages/Admins.jsx#L4)) pulls the `SUPER_ADMIN` **constant**
for a display badge on a *different* payload (an admin-list row, which uses `role_code`).

---

## 4. Hooks

There is exactly **one** auth hook.

### `useAdminAuth()` — [AdminAuthContext.jsx:7](frontend-admin/src/context/AdminAuthContext.jsx#L7)

```js
export function useAdminAuth() {
  const v = useContext(Ctx)
  if (!v) throw new Error('useAdminAuth must be used inside <AdminAuthProvider>')
  return v
}
```

- Throws (rather than returning `null`) when used outside the provider — a misuse becomes
  a loud crash instead of a silent "logged out" render. Since `AdminAuthProvider` wraps the
  whole `<Routes>` tree, this cannot fire in practice.
- **Exposes the raw payload** (`admin`) *and* semantic helpers (`hasPermission`,
  `hasRole`, `isSuperAdmin`, `isAuthed`) from the same object. Nothing prevents a consumer
  from re-deriving authorization from `admin` directly; that is precisely the class of bug
  the uncommitted `auth/roles.js` change was made to eliminate (§13.1).

`useAuth()`, `useCurrentAdmin()`, `usePermissions()`, `useRole()` — named in the review
brief — **do not exist** in this codebase. There is no separate permissions hook, no
`<Can>`/`<RequirePermission>` component, and no HOC.

Consumers of `useAdminAuth()` (21 import sites, 24 call sites):

| Destructured | Files |
| --- | --- |
| `{ hasPermission }` | Dashboard, Users' detail pages (UserDetail), Plans, Payments (×2), Brokers, Security (×3), AI, Support, Holidays, CreditPackages, Announcements, Backtest, backtest_v2/HistoricalData, backtest_v2/ReplayPanel, backtest_v2/ReplayTester |
| `{ admin: me, hasPermission }` | Admins |
| `{ isSuperAdmin }` | operations/OperationsCenter, research/ResearchWorkbench |
| `{ logout, hasPermission, isSuperAdmin }` | AdminLayout |
| `{ isAuthed, loading }` | App (`Protected`) |
| `{ login }` | Login |

`hasRole()` is exported and never called. `roleLabel()` is exported and never called.

---

## 5. Authorization mechanisms

Seven distinct mechanisms exist. All are client-side and all are cosmetic (§11).

| # | Mechanism | Where | Basis |
| --- | --- | --- | --- |
| 1 | Route gate (`Protected`) | [App.jsx:33](frontend-admin/src/App.jsx#L33) | **Authentication only** — no role or permission input |
| 2 | Nav section/item filtering | [AdminLayout.jsx:94](frontend-admin/src/components/AdminLayout.jsx#L94) | **Role** (`superOnly` + `isSuperAdmin`) |
| 3 | Nav tooltip text | [AdminLayout.jsx:120](frontend-admin/src/components/AdminLayout.jsx#L120) | **Permission** (`item.perm`) — affects `title` only, not visibility |
| 4 | Whole-page refusal card | [OperationsCenter.jsx:111](frontend-admin/src/pages/operations/OperationsCenter.jsx#L111), [ResearchWorkbench.jsx:62](frontend-admin/src/pages/research/ResearchWorkbench.jsx#L62) | **Role** (`isSuperAdmin`) |
| 5 | Conditional rendering of controls | 13 pages (§5.1) | **Permission** |
| 6 | Disabled (not hidden) controls | [Brokers.jsx:112](frontend-admin/src/pages/Brokers.jsx#L112), [Payments.jsx:484](frontend-admin/src/pages/Payments.jsx#L484) | **Permission** |
| 7 | Conditional data fetching | [UserDetail.jsx:74-75](frontend-admin/src/pages/UserDetail.jsx#L74-L75) | **Permission** |

Plus two non-authorization gates that look similar and are worth not confusing with the
above: the `disabled: true` flags on unbuilt nav items
([AdminLayout.jsx:51-57](frontend-admin/src/components/AdminLayout.jsx#L51-L57)) mean
"not implemented yet", and `Admins.jsx`'s `isMe` check suppresses self-management buttons
for a safety reason, not a permission reason.

### 5.1 Permission-gated UI, per page

| Page | Pattern | Codes |
| --- | --- | --- |
| Dashboard | Tiles link or not (`to={… ? '/x' : null}`); quick actions rendered conditionally | `users.view`, `subs.view`, `payments.view`, `reports.view`, `support.view`, `notifications.view`, `brokers.view`, `security.view`, `notifications.manage`, `subs.manage_plans`, `admins.manage`, `audit.view`, `holidays.view` |
| UserDetail | Per-action buttons; two fetches skipped when unauthorised | `users.view_activity`, `users.view`, `users.suspend`, `users.activate`, `users.reset_password`, `users.edit`, `subs.manage_grants`, `users.delete` |
| Plans | Create button + per-row edit | `subs.manage_plans` |
| CreditPackages | `canManage` gate | `subs.manage_plans` |
| Payments | `canRefund`; provider toggle disabled + edit hidden | `payments.manage_refunds`, `payments.manage_providers` |
| Brokers | Toggle disabled; edit hidden | `brokers.manage` |
| Security | Block/unblock, clear lockout, config edit | `security.manage` |
| AI | `canManage` gate | `ai.manage` |
| Support | `canManage` gate | `support.manage` |
| Holidays | `canManage` gate | `holidays.manage` |
| Announcements | `canManage` gate | `notifications.manage` |
| Admins | `canManage` gate (+ `isMe` self-protection) | `admins.manage` |
| Backtest | `canManage` gate | `ai.manage` |
| backtest_v2/HistoricalData | `canManage` gate (backfill) | `ai.manage` |
| backtest_v2/ReplayPanel | `canRun` gate | `ai.view` |
| backtest_v2/ReplayTester | `canView` gate — **file is not routed** (App.jsx:27 comment) | `ai.view` |

### 5.2 Pages with no client-side authorization at all

`Users`, `UserActivity`, `UserCredits`, `UserPayments`, `AuditLog`, `Reports` never call
`useAdminAuth()`. They are read-only listing pages whose data endpoints are permission-
guarded server-side; if the admin lacks the permission the page renders its shell and
shows its own "failed to load" error from the 403.

---

## 6. Route protection

Every route below `/` is wrapped once by `<Protected>` — the guard is applied to the
layout route, not per child, so it is impossible to add a child route that skips it.

| Route | Component | Protection | Role req. | Permission req. (client) | Redirect / unauthorized behaviour |
| --- | --- | --- | --- | --- | --- |
| `/login` | Login | none (public) | — | — | Renders even when already signed in |
| `/` (index) | Dashboard | `Protected` | — | — | Unauth → `/login` with `state.from` |
| `/dashboard` | Dashboard | `Protected` | — | — (tiles gated individually) | Backend 403 → in-page error |
| `/users` | Users | `Protected` | — | — | Backend 403 → in-page error |
| `/users/:id` | UserDetail | `Protected` | — | per-action gates | Backend 403 → in-page error |
| `/users/:id/credits` | UserCredits | `Protected` | — | — | Backend 403 → in-page error |
| `/users/:id/payments` | UserPayments | `Protected` | — | — | Backend 403 → in-page error |
| `/user-activity` | UserActivity | `Protected` | — | — | Backend 403 → in-page error |
| `/plans` | Plans | `Protected` | — | `subs.manage_plans` for writes | Backend 403 → in-page error |
| `/payments` | Payments | `Protected` | — | `payments.manage_*` for writes | Backend 403 → in-page error |
| `/brokers` | Brokers | `Protected` | — | `brokers.manage` for writes | Backend 403 → in-page error |
| `/security` | Security | `Protected` | — | `security.manage` for writes | Backend 403 → in-page error |
| `/ai` | AI | `Protected` | — | `ai.manage` for writes | Backend 403 → in-page error |
| `/support` | Support | `Protected` | — | `support.manage` for writes | Backend 403 → in-page error |
| `/audit-log` | AuditLog | `Protected` | — | — | Backend 403 → in-page error |
| `/holidays` | Holidays | `Protected` | — | `holidays.manage` for writes | Backend 403 → in-page error |
| `/admins` | Admins | `Protected` | — | `admins.manage` for writes | Backend 403 → in-page error |
| `/credit-packages` | CreditPackages | `Protected` | — | `subs.manage_plans` for writes | Backend requires `subs.manage_plans` even to **read** (§11.2) |
| `/reports` | Reports | `Protected` | — | — | Backend 403 → in-page error |
| `/announcements` | Announcements | `Protected` | — | `notifications.manage` for writes | Backend 403 → in-page error |
| `/backtest` | Backtest | `Protected` | — | `ai.manage` for run | **Backend router retired — endpoints 404** (§11.3) |
| `/ai-backtest/historical` | HistoricalData | `Protected` | — | `ai.manage` for backfill | Backend 403 → in-page error |
| `/operations` | OperationsCenter | `Protected` **+ in-page role guard** | `super_admin` | none possible by design | Renders a "Restricted" card; a 403 from the API is separately translated to a plain-English message ([OperationsCenter.jsx:78](frontend-admin/src/pages/operations/OperationsCenter.jsx#L78)) |
| `/research` | ResearchWorkbench | `Protected` **+ in-page role guard** | `super_admin` | none possible by design | Renders a "Restricted" `<Empty>`; also **skips the shell fetch** entirely when not Super Admin ([ResearchWorkbench.jsx:58](frontend-admin/src/pages/research/ResearchWorkbench.jsx#L58)) |
| `*` | — | — | — | — | `<Navigate to="/" replace>` → then `Protected` applies |

**Redirect semantics.** `Protected` renders
`<Navigate to="/login" replace state={{from: loc}} />`, and `Login` reads
`loc.state?.from?.pathname` after a successful sign-in. This is the deep-link-preserving
path. The interceptor's `window.location.href = '/login'` (session expiry) is the
*non*-preserving path — the two unauthenticated exits behave differently.

**There is no 403 page and no `<RequireRole>` route wrapper.** Role enforcement for the
two Super-Admin routes lives *inside* the two page components, meaning the route matches,
the component mounts, and the component decides to render a refusal card instead of its
content.

---

## 7. Navigation

The sidebar in [AdminLayout.jsx](frontend-admin/src/components/AdminLayout.jsx) is the
only navigation surface. There is no top nav menu, no breadcrumb component, and no
user/profile dropdown. The top bar is two static elements: a title derived from the path
by `pageTitle()` (a `startsWith` chain, [AdminLayout.jsx:60](frontend-admin/src/components/AdminLayout.jsx#L60))
and a hard-coded "Admin Console" chip.

`NAV` is a flat array of section headers (`{section: true}`) and links
(`{to, label, icon, perm?, superOnly?, disabled?}`).

**Visibility rule — one line:**

```js
const nav = NAV.filter((item) => !item.superOnly || isSuperAdmin)
```

That is the *entire* visibility computation. Consequences:

1. **`perm` does not hide anything.** `item.perm` is used only to choose a tooltip
   (`title={… hasPermission(item.perm) ? '' : 'No permission'}`). A Support Manager sees
   "Payments", "Brokers", "Reports", "Admin Users" etc. in the sidebar, can click them,
   and lands on a page that then fails its data fetch with a 403.
2. **`superOnly` does hide.** Both the "Operations"/"Research" section headers and their
   links carry `superOnly: true`, so a non-Super-Admin sees neither the headers nor the
   items — no empty section remains.
3. **`disabled` items still render** as `NavLink`s with `data-disabled="true"`; the click
   is cancelled with `e.preventDefault()`. Six items are currently disabled placeholders
   (Replay Tester, Backtest Results, Strategy Comparison, Replay Logs, System). They are
   visible to every role.
4. **Quick actions on the Dashboard** are the one nav-like surface that *is* permission-
   filtered ([Dashboard.jsx:170-190](frontend-admin/src/pages/Dashboard.jsx#L170-L190)),
   as are the KPI tile links (`to={hasPermission(…) ? '/x' : null}`). So the Dashboard is
   stricter about navigation than the sidebar is.

The sidebar brand block hard-codes the sub-title "Super Admin" regardless of the signed-in
admin's actual role, and no longer displays the admin's name or email.

---

## 8. API layer

Single axios instance ([api.js:16](frontend-admin/src/api.js#L16)):

```js
export const api = axios.create({ baseURL: '/api', timeout: 20000 })
```

Relative `baseURL` — in dev, Vite proxies `/api` → `http://localhost:8000`
(`vite.config.js`); in production the console is served from the same origin as the API.
No `VITE_API_URL` env var and no CORS configuration are involved.

| Concern | Implementation |
| --- | --- |
| **Auth header** | Request interceptor injects `Authorization: Bearer <access token>` on every request when a token exists. Never conditional on the URL — the login call itself carries a stale header if one is in storage (the backend ignores it). |
| **Token injection point** | `tokenStore.get()` is read per request, so a refreshed token is picked up immediately by subsequent calls. |
| **Retry logic** | Exactly one retry, only for 401, only after a successful refresh (§1.4). No retry on 5xx, no backoff, no idempotency distinction. |
| **Unauthorized (401)** | Refresh → replay, or hard redirect to `/login`. |
| **Forbidden (403)** | **No global handling.** Each caller unwraps it. Two error shapes exist in the backend: a bare string `detail` and an object `detail: {code, message}`, and pages defensively handle both: `typeof d === 'string' ? d : d?.message`. |
| **Timeouts / network errors** | Rejected to the caller with no special treatment. In `AdminAuthProvider.refresh()` this is indistinguishable from a 401 and clears the session. |
| **Blob downloads** | `research.downloadSession` / `downloadComparison` use `responseType: 'blob'` through the same instance, so exports are authenticated by the same interceptor rather than by a signed URL. |
| **Polling** | `OperationsCenter` (interval selectable 5/15/30/60s, guarded by an `inFlight` ref) and the Research Execute tab poll through the same instance; a 401 during polling triggers the same refresh path. |

`adminApi` is a flat, hand-written map of ~120 endpoints grouped by domain
(`users`, `plans`, `payments`, `brokers`, `ai`, `backtest`, `backtestV2`, `support`,
`audit`, `holidays`, `creditPackages`, `announcements`, `reports`, `admins`,
`operations`, `research`, `security`). Every method is `api.<verb>(…).then(r => r.data)`.
No request/response schema validation, no generated client.

---

## 9. Permission system

**Model.** Permissions are dot-notated `module.action` strings
(`users.suspend`, `payments.manage_refunds`). They are assigned to **roles**, never to
individual admins. The authenticated admin receives a flat `permissions: string[]` in the
auth payload; the frontend never fetches the permission catalogue.

**Implementation** ([AdminAuthContext.jsx:47](frontend-admin/src/context/AdminAuthContext.jsx#L47)):

```js
const hasPermission = (code) => {
  if (!admin) return false                     // 1. anonymous → deny
  if (adminIsSuperAdmin(admin)) return true    // 2. role bypass
  if (admin.permissions?.includes('*')) return true   // 3. wildcard grant
  return (admin.permissions || []).includes(code)     // 4. exact match
}
```

Evaluation notes:

- **Fails closed** at every step: no admin, missing `permissions`, malformed role — all
  deny.
- **Exact string match only.** There is no prefix or namespace wildcard: `users.*` would
  not grant `users.edit`, and `hasPermission('users')` matches nothing. The only wildcard
  is the literal single-character `'*'`.
- **Two independent bypasses.** The role check (step 2) and the wildcard check (step 3)
  are deliberately separate. Today the backend emits `["*"]` precisely for `super_admin`,
  so they always coincide; the comment in-file records the intent — a wildcard grant is a
  permission, and Super Admin status is a role, and neither is derived from the other.
- **Evaluation is centralised** in the provider; **usage is fully decentralised.** Every
  page calls `hasPermission('literal.string')` at its own point of use. There is no
  registry mapping pages/actions → codes (with the partial exception of the `NAV` table's
  `perm` field, which is used only for tooltips). Permission code strings are duplicated
  as literals across ~16 files.
- **No memoisation.** `hasPermission` is a new closure each provider render; components
  that pass it to `useCallback`/`useMemo` dependency arrays would re-run. None currently do.

**Backend counterpart** (`admin/dependencies.py:67`) is the same shape:
`super_admin` role short-circuits, otherwise the role's permission codes are loaded from
`admin_role_permissions` and checked for exact membership. The `'*'` sentinel is a
*response* convention only — it is expanded to real rows in the seed
(`_ROLE_PERMISSIONS["super_admin"] = ["*"]` → all permission ids).

**Catalogue** (backend `db/admin_models.py:127`) — 33 codes:

`dashboard.view` · `users.view` · `users.edit` · `users.suspend` · `users.activate` ·
`users.delete` · `users.reset_password` · `users.view_activity` · `subs.view` ·
`subs.manage_plans` · `subs.manage_grants` · `payments.view` ·
`payments.manage_providers` · `payments.manage_refunds` · `brokers.view` ·
`brokers.manage` · `security.view` · `security.manage` · `settings.view` ·
`settings.manage` · `ai.view` · `ai.manage` · `support.view` · `support.manage` ·
`holidays.view` · `holidays.manage` · `reports.view` · `reports.export` ·
`notifications.view` · `notifications.manage` · `admins.view` · `admins.manage` ·
`audit.view`

Never referenced by the frontend: `settings.view`, `settings.manage` (the System page is
still a disabled placeholder) and `reports.export` (export was dropped in Phase 4).

---

## 10. Role system

**Model.** One role per admin (`AdminUser.role_id` → `AdminRole`). Roles are seeded rows
with `code`, `name`, `description`, `is_system`, `sort_order`
(backend `db/admin_models.py:118`). The role's *code* is what travels in the JWT (`role`
claim) and in the auth payload.

**Roles currently supported** (all five are `is_system = True`):

| Code | Name | Permission set |
| --- | --- | --- |
| `super_admin` | Super Admin | `["*"]` — every permission, plus the two role-gated Super-Admin-only surfaces |
| `admin` | Admin | Everything except `users.delete`, `payments.manage_providers`, `ai.manage`, `settings.manage`, `admins.view`, `admins.manage` |
| `support_manager` | Support Manager | `dashboard.view`, `users.view`, `users.view_activity`, `users.reset_password`, `support.view`, `support.manage` |
| `finance_manager` | Finance Manager | `dashboard.view`, `subs.view`, `subs.manage_plans`, `payments.view`, `payments.manage_providers`, `reports.view`, `reports.export` |
| `content_manager` | Content Manager | `dashboard.view`, `notifications.manage`, `settings.view` |

**Where roles are used in the frontend:**

| Location | Use |
| --- | --- |
| [auth/roles.js](frontend-admin/src/auth/roles.js) | The only interpreter of the role field: `SUPER_ADMIN` constant, `roleOf`, `hasRole`, `isSuperAdmin`, `roleLabel` |
| [AdminAuthContext.jsx:58-59](frontend-admin/src/context/AdminAuthContext.jsx#L58-L59) | Wraps those into context values `isSuperAdmin` / `hasRole` |
| [AdminAuthContext.jsx:49](frontend-admin/src/context/AdminAuthContext.jsx#L49) | Super Admin bypass inside `hasPermission` |
| [AdminLayout.jsx:94](frontend-admin/src/components/AdminLayout.jsx#L94) | Hides `superOnly` nav sections/items |
| [OperationsCenter.jsx:111](frontend-admin/src/pages/operations/OperationsCenter.jsx#L111) | Whole-page refusal |
| [ResearchWorkbench.jsx:58,62](frontend-admin/src/pages/research/ResearchWorkbench.jsx#L58-L62) | Whole-page refusal + skips the shell fetch |
| [Admins.jsx:117](frontend-admin/src/pages/Admins.jsx#L117) | Display only: warn-coloured badge when a **listed row**'s `role_code === SUPER_ADMIN`. Note this row payload uses `role_code`/`role_name`, a *different* field naming than the auth payload's `role`/`role_name` |
| [Admins.jsx:196-292](frontend-admin/src/pages/Admins.jsx#L196-L292) | Role assignment UI: the `<select>` is populated from `GET /admin/admins/roles`, so the frontend hard-codes no role list |

**The frontend only ever distinguishes Super Admin from everyone else.** `hasRole(code)`
exists for the other four but is never called; all non-Super-Admin differentiation is done
through permissions.

**Role-related safety rules live server-side only** (`admin/routes/admins.py`): you cannot
modify/deactivate/delete your own account through this endpoint, and you cannot delete,
deactivate or demote the last active `super_admin`. The frontend mirrors only the first of
these (the `isMe` check hides the buttons); the last-super-admin rules surface as a 400
with an explanatory message in an `alert()`.

---

## 11. Security boundaries

### 11.1 Classification

| Check | Frontend | Backend | Verdict |
| --- | --- | --- | --- |
| Is there a session at all (`Protected`, `isAuthed`) | ✅ | ✅ `get_current_admin` (401) | Convenience + enforced |
| Token validity / signature / audience / type | ❌ never inspected | ✅ `decode()` with admin-only secret and `aud="thetradelogic-admin"` | **Backend only** |
| Account active / not locked | ❌ `is_active` ignored | ✅ 403 in `get_current_admin` | **Backend only** |
| Refresh-token revocation | ❌ | ✅ `AdminSession.revoked` / expiry checked | **Backend only** |
| Permission checks (`hasPermission`) | ✅ UI gating | ✅ `require_permission(code)` on every non-auth admin route | Convenience + enforced |
| Super Admin checks (`isSuperAdmin`) | ✅ page guard + nav | ✅ `require_super_admin` on `/admin/operations/*` and `/admin/research/*` | Convenience + enforced |
| Last-super-admin / self-edit protections | partial (`isMe` only) | ✅ | **Backend only** |
| Nav visibility | ✅ | n/a | **Frontend cosmetic** |

Every admin endpoint except `POST /auth/login`, `POST /auth/refresh` (public by
necessity) and `POST /auth/logout` + `GET /auth/me` (authenticated, unpermissioned) is
wrapped in `require_permission(...)`, which itself depends on `get_current_admin`. That
was verified by enumerating every `@router.<verb>` decorator across
`backend/admin/routes/*.py`.

The two Super-Admin domains use their own guard rather than a permission code, and both
document why: the capability is deliberately non-delegable, so no permission exists that
could grant it via a role edit (`api/operations_dashboard_routes.py:51`,
`research/routes.py:43`).

**Conclusion: no page's authorization depends on the frontend.** Bypassing every client
check (editing `localStorage`, calling the context directly from devtools, or hitting the
routes by URL) yields a page shell whose every data call 401s or 403s. The frontend checks
exist to avoid showing controls that would fail, not to protect data.

### 11.2 Front/back mismatches observed

- **Credit Packages** — the sidebar gates the item on `subs.view`, but every
  `/admin/credit-packages` endpoint, including the list, requires `subs.manage_plans`. A
  role with `subs.view` but not `subs.manage_plans` would see the link and get a 403 on
  load. (No seeded role is in that state today: `admin` and `finance_manager` both hold
  `subs.manage_plans`.)
- **Sidebar vs. backend permissions generally** — since `perm` never hides an item (§7),
  every non-Super-Admin role sees links to pages it cannot load.
- **`ai.view` vs `ai.manage` on backtest pages** — the Historical Replay page's
  `POST /replay/run` requires only `ai.view` server-side, while backfill requires
  `ai.manage`; the page's `canManage` gate matches.

### 11.3 Dead endpoint surface

`adminApi.backtest.*` targets `/api/admin/backtest/...`, but the v1 backtest router was
retired in the V2 migration (`main.py:305-312`) and never re-mounted — the `admin_router`
includes no backtest sub-router. The `/backtest` page is routed and its nav item is
visible to anyone with `ai.view`; its calls return 404, which the page surfaces as a load
error. This is a stale-surface issue rather than an auth issue, but it is reachable
through the authenticated navigation.

---

## 12. Search results

Counts are over `frontend-admin/src` unless noted.

| Term | Where it appears | Summary |
| --- | --- | --- |
| `role` | `auth/roles.js` (definition + doc block), `AdminAuthContext.jsx` (import/alias only), `Admins.jsx` (`role_code`, `roles` list, role `<select>`), `api.js` (`admins.listRoles`), `App.jsx`/`AdminLayout.jsx` (comments), `Switch.jsx` (`role="switch"` — ARIA, unrelated) | The raw `admin.role` field is read in **exactly one place**: `roleOf()` in `auth/roles.js`. Everything else goes through helpers or is about a different payload. |
| `role_name` | `auth/roles.js:46` (`roleLabel`), `Admins.jsx:118` (list rows) | Never rendered for the authenticated admin — `roleLabel()` has no callers. The `Admins.jsx` use is a per-row field from a different endpoint. |
| `permissions` | `AdminAuthContext.jsx:50-51` (the only auth reader), `auth/roles.js` (comments), `Plans.jsx:32-38` + `:174-286` (**unrelated** — subscription-plan feature flags) | Auth permissions are read in one function. `Plans.jsx`'s `plan.permissions` is a product-feature object and shares only the word. |
| `hasPermission` | Defined once (`AdminAuthContext.jsx:47`); consumed at ~45 call sites across 16 files: AdminLayout, Dashboard, UserDetail, Plans, Payments, Brokers, Security, AI, Support, Holidays, CreditPackages, Announcements, Admins, Backtest, HistoricalData, ReplayPanel, ReplayTester | The dominant authorization primitive in the app. |
| `hasRole` | `auth/roles.js:32` (definition), `AdminAuthContext.jsx:3,59,65` (re-export through context) | **Zero consumers.** Provided API surface only. |
| `isSuperAdmin` | `auth/roles.js:37` (definition), `AdminAuthContext.jsx:3,55-58,65`, `AdminLayout.jsx:91,94`, `OperationsCenter.jsx:50,111`, `ResearchWorkbench.jsx:35,58,62` | Three consumers, all for the two Super-Admin-only domains plus their nav entries. |
| `Protected` | `App.jsx:33` (definition), `App.jsx:49-51` (single use, wrapping `AdminLayout`) | One guard, applied once, to the layout route — hence to every child route. Authentication only. |
| `AdminAuthContext` | Imported by 21 files: `App.jsx`, `AdminLayout.jsx`, and 19 pages | The context module is the only auth import path in the app. |
| `useAdminAuth` | Defined `AdminAuthContext.jsx:7`; called at 24 sites in 21 files (`Security.jsx` and `Payments.jsx` call it from multiple sub-components) | The single hook. No alternative accessor exists. |
| `SUPER_ADMIN` | `auth/roles.js:18` (definition, used by `isSuperAdmin`), `Admins.jsx:4,117` | The only string-literal role comparison left outside `auth/roles.js` is `Admins.jsx`'s badge, and it uses the shared constant against a different payload's `role_code`. |
| `localStorage` | `api.js:7-12` (token store), `OperationsCenter.jsx:57,91` (refresh-interval preference — not auth) | Token persistence is confined to `tokenStore`. |
| `'super_admin'` string literal | **Not present** in `frontend-admin/src` outside `auth/roles.js:18` | After the uncommitted change, the literal is defined once. |

---

## 13. Architecture assessment

### 13.1 Consistency

**Strong where it has been consolidated.** Authentication has exactly one provider, one
hook, one token store, one axios instance, one route guard. There is no second way to log
in, no component that reads `localStorage` directly, and no fetch that bypasses the
interceptor.

**The role-check layer was inconsistent and is mid-consolidation.** The uncommitted diff
shows three different spellings of the same question coexisting in the tracked tree:

- `admin?.role === 'super_admin'` (AdminLayout)
- `admin && admin.role !== 'super_admin'` (OperationsCenter — note this also fails *open*
  while `admin` is null)
- `admin?.role?.code === 'super_admin'` (ResearchWorkbench — reads a nested shape that
  **does not exist** in the contract, so it evaluated to `undefined !== 'super_admin'`,
  i.e. every user including a real Super Admin was shown the "Restricted" card)

`auth/roles.js` was introduced to make that impossible, and its header comment documents
the failure mode precisely. All three sites now read `isSuperAdmin` from the context.
**This work is not yet committed.**

**Permission checks are consistent in form** (`hasPermission('literal')`) but the naming
around them is not: `canManage`, `canRefund`, `canRun`, `canView`, and bare inline calls
all appear for the same pattern.

### 13.2 Duplication

- **Permission code strings** are repeated as literals across 16 files with no shared
  constants module (contrast with `SUPER_ADMIN`, which now is one). A backend rename would
  require a text search across pages, and a typo silently denies rather than errors.
- **Error unwrapping** — the idiom
  `const d = ex.response?.data?.detail; typeof d === 'string' ? d : d?.message || 'fallback'`
  is re-implemented in nearly every page (and a `message()` helper exists only inside
  `pages/research/widgets.jsx`). This is a consequence of the backend emitting two
  `detail` shapes.
- **Route → title mapping** (`pageTitle`) duplicates the route table's knowledge in a
  `startsWith` chain that is order-sensitive (a comment at
  [AdminLayout.jsx:68](frontend-admin/src/components/AdminLayout.jsx#L68) records a bug
  already hit here), and the `NAV` table duplicates it a third time.
- **Two Super-Admin refusal cards** with near-identical copy, one per page, because there
  is no shared `<Restricted>` component or route-level role guard.

### 13.3 Separation of concerns

Clean where it counts:

- `auth/roles.js` is pure and React-free — payload interpretation isolated from state.
- `AdminAuthContext` owns session state and exposes decisions, not data shapes.
- `api.js` owns transport and token lifecycle; no component knows a token exists.

Blurred in two places:

- **The context exposes both the raw payload and the semantic helpers.** Anything can
  re-derive authorization from `admin` — the exact hole the role helpers were added to
  close. Nothing structurally prevents the next page from doing it again.
- **Role enforcement for the two restricted domains lives in page components** rather than
  in the routing layer, so `App.jsx`'s route table does not tell the whole authorization
  story; it carries comments explaining what the components do instead.

### 13.4 Coupling

- 21 of ~35 modules import `AdminAuthContext` — high fan-in, but to a single stable
  interface, which is the right direction for this kind of dependency.
- Pages are coupled to **permission code strings** (a backend-owned vocabulary) rather
  than to capability names of their own. That is direct coupling to the backend's seed
  data.
- `Admins.jsx` is coupled to two different payload vocabularies at once (`role` from the
  auth contract, `role_code`/`role_name` from the admin-list contract) — currently handled
  by a comment.
- The provider value is not memoised, so all 21 consumers are coupled to the provider's
  render identity. Benign today.

### 13.5 Maintainability

Positives: the whole system fits in five files, the flow is linear and readable, failure
modes are closed by default, and the recently added comments explain *why* (non-delegable
capabilities, roles vs permissions, the nested-role trap) rather than *what*.

Frictions: adding a new permission-gated page currently means touching `NAV`, `pageTitle`,
`App.jsx`, `api.js` and the page itself, with the permission string typed by hand in two
of them. There are no tests for the frontend auth layer — no test tooling is configured in
`frontend-admin/package.json` at all — so the `admin.role.code` class of regression is
caught only by manual use.

### 13.6 Inconsistent patterns, recorded not fixed

1. `perm` in `NAV` implies gating but only sets a tooltip; `superOnly` genuinely hides.
2. `isSuperAdmin` is a boolean while `hasRole` is a function on the same object.
3. Two different unauthenticated exits with different UX (router `Navigate` + `state.from`
   preserved, vs. `window.location.href` with the deep link lost).
4. Role enforcement is in-component for `/operations` and `/research`, but the route-level
   guard exists and enforces only authentication.
5. Permission-missing UI is sometimes hidden (most pages) and sometimes disabled
   (Brokers/Payments switches).
6. Six pages do no client-side authorization at all while comparable pages do.
7. `hasRole()` and `roleLabel()` are exported, documented and unused.
8. `role`/`role_name` (auth payload) vs `role_code`/`role_name` (admin-list payload) —
   two vocabularies for one concept.
9. The login payload and the `/me` payload are different shapes for the same in-memory
   object.
10. `Login` is reachable while authenticated; the Backtest nav item is reachable while its
    backend is retired.

---

## 14. Final summary

### Strengths

- **A single source of truth per concern.** One provider, one hook, one token store, one
  HTTP client, one route guard, and — since the in-flight change — one interpreter of the
  role field.
- **Fails closed.** Missing admin, missing `permissions`, malformed role, unknown
  permission code: every path denies. `Protected` blocks rendering during boot, so no
  protected content flashes before identity is known.
- **The security boundary is genuinely server-side.** Every admin endpoint outside
  `/auth/*` carries `require_permission`, the two Super-Admin domains carry
  `require_super_admin`, and admin tokens are signed with a separate secret and audience
  so they cannot be replayed against the user API. Nothing in the console is protected by
  the console.
- **Roles and permissions are kept conceptually separate**, both in the frontend helper
  and in the backend guard, with the non-delegable Super-Admin capabilities deliberately
  given no permission code.
- **Transparent token lifecycle.** Single-flight refresh, one retry maximum, no
  possibility of a refresh loop.
- **The code documents its own traps** — the `auth/roles.js` header, the `superOnly`
  rationale in `NAV`, and the "convenience, not the security boundary" comments on both
  restricted pages.

### Weaknesses

- **Nav `perm` gating is decorative.** Roles other than Super Admin see a sidebar full of
  links to pages they cannot load; the failure is discovered as an error message after
  navigation.
- **Permission strings are scattered literals** across 16 files, unchecked and un-shared.
- **The context leaks the raw payload alongside the helpers**, keeping the door open for
  the payload-shape bugs the helpers were introduced to prevent.
- **Role enforcement is not expressible in the route table**; it lives inside two page
  components, duplicated.
- **`refresh()` treats every failure as an invalid session** — a transient network error
  or a backend 500 during boot logs the admin out.
- **No client-side notion of expiry**: `expires_in` is discarded and the JWT is never
  decoded, so there is no warning, no proactive refresh, and no idle timeout.
- **No multi-tab session coordination** and no `storage` listener.
- **No tests at all** for the frontend auth layer.
- Dead surface: `hasRole`, `roleLabel`, the unrouted `ReplayTester`, and the `/backtest`
  page pointing at a retired router.

### Risks

| Risk | Nature |
| --- | --- |
| Tokens in `localStorage` | Any XSS on the admin origin yields both tokens and an 8-hour refresh window. An `httpOnly` cookie would not be readable; this is a deliberate trade-off, not an accident, but it is the largest single exposure. |
| Uncommitted auth changes | The role-helper consolidation — including the fix for `ResearchWorkbench`'s non-existent `admin.role.code` — exists only in the working tree. An unrelated `git checkout`/`stash` loses it, and `auth/roles.js` is untracked so it would not even be restorable from the index. |
| Silent logout on transient errors | A backend blip during boot clears stored tokens and forces re-login. Low severity, easy to misdiagnose as a session bug. |
| Permission-string drift | Renaming a backend permission code leaves the frontend silently denying (hidden buttons) with no error anywhere. Deletion of a code is equally silent. |
| Sidebar/backend divergence | The Credit Packages `subs.view` vs `subs.manage_plans` mismatch shows the pattern; no mechanism keeps `NAV.perm` and the route's `require_permission` in agreement. |
| Non-Super-Admin roles are effectively untested | The console has been built and exercised as a Super Admin tool (the login page and sidebar both say "Super Admin"); the four subordinate roles' end-to-end experience rests on gating that has never had to be right. |

### Architectural observations

1. **The architecture is a thin, correct authentication layer plus a decorative
   authorization layer.** That is a defensible design given every endpoint enforces
   independently — but it should be a stated position, because the code currently reads as
   though the frontend gating is meaningful in some places (page refusal cards, disabled
   switches) and admits it is not in others (the comments on `/operations` and
   `/research`).
2. **Role handling has just been centralised; permission handling has not.** `auth/roles.js`
   is exactly the pattern the permission codes lack — one module owning the vocabulary,
   with pages asking semantic questions rather than string-matching.
3. **The two-shape auth contract is the root of several small awkwardnesses** (`roleLabel`'s
   fallback, fields that are present only after a reload). A single shape from both
   endpoints would remove a whole category of "it works after refresh" confusion.
4. **Authorization is expressed at three altitudes** — route (authentication), component
   (role), and control (permission) — with no shared vocabulary between them. The route
   table is the natural home for the role tier and does not currently hold it.
5. **Nothing here is broken in a way that permits unauthorized access.** The weaknesses are
   about consistency, discoverability, and the experience of non-Super-Admin roles, not
   about data exposure.

---

*End of review. No source files were modified.*
