# Frontend Admin — Auth & Authz Consistency Pass

**Date:** 2026-07-31
**Scope:** `frontend-admin/` only. No backend, API, database or JWT change.
**Predecessor:** [frontend_admin_auth_review.md](frontend_admin_auth_review.md) — the
"before" picture. This document records what the pass changed and, as importantly, what
it deliberately did not.

> Placed beside the review it follows, matching the path that review was requested at.

---

## 1. The one consistent way to do each thing

| Question | The single answer | Where |
| --- | --- | --- |
| Is there a session? | `isAuthed` | `AdminAuthContext` |
| Who is signed in? | `identity` — `{id, name, email, roleLabel}`, display only | `AdminAuthContext` |
| What role? | `isSuperAdmin` / `hasRole(ROLE.X)` | `auth/roles.js` |
| What may they do? | `hasPermission(PERM.X)` | `auth/permissions.js` |
| Protect a route (auth) | `<RequireAuth>` on the layout route | `auth/guards.jsx` |
| Protect a route (role) | `<RequireRole role={…}>` in the route table | `auth/guards.jsx` |
| Show a nav item | `perm` / `superOnly` in `NAV` — both hide | `AdminLayout` |
| Show a control | `hasPermission(PERM.X)` at the control | pages |
| Read the wire payload | `normalizeAdmin()` — nothing else, anywhere | `auth/session.js` |

The `auth/` directory is now the whole authorization layer, and nothing outside it
interprets an authentication payload.

---

## 2. Files

**Added**

| File | Purpose |
| --- | --- |
| `src/auth/permissions.js` | `PERM` — all 33 backend permission codes, frozen. |
| `src/auth/session.js` | `normalizeAdmin()` (the only raw-payload reader) + `classifyAuthFailure()`. |
| `src/auth/guards.jsx` | `<RequireAuth>`, `<RequireRole>`. |

**Modified**

| File | Change |
| --- | --- |
| `src/auth/roles.js` | `SUPER_ADMIN` → `ROLE` (all five codes); predicates now take an `AdminSession`, not a raw payload. |
| `src/context/AdminAuthContext.jsx` | Normalises both payloads; stops exposing `admin`; exposes `identity` + `bootFailure`; failure-aware `refresh()`; memoised value. |
| `src/App.jsx` | `Protected` → `<RequireAuth>`; role requirements moved into the route table. |
| `src/components/AdminLayout.jsx` | `perm` now hides; empty section headers collapse; `PERM` constants; real role in the brand sub-title. |
| `src/pages/operations/OperationsCenter.jsx` | In-page role guard removed (now route-level). |
| `src/pages/research/ResearchWorkbench.jsx` | In-page role guard + fetch-skip removed (now route-level). |
| `src/pages/Admins.jsx` | `admin` → `identity`; `ROLE.SUPER_ADMIN`; modal prop `admin` → `row`. |
| 15 further pages | Permission literals → `PERM` constants. |

---

## 3. Decisions

**Navigation hides rather than disables.** `perm` and `superOnly` had implied the same
thing and done different things — `superOnly` hid, `perm` only chose a tooltip. One rule
now: *you see what you can open*. Section headers whose items all vanish are dropped, so
hiding never leaves an empty heading. The alternative (render everything, grey out the
inaccessible) was considered and rejected: it would have made `/operations` and
`/research` visible to every role, advertising two capabilities that are deliberately
non-delegable.

**The context no longer exposes the raw payload.** Every authorization bug this codebase
has had came from a page re-deriving a decision from `admin` — three spellings of "is this
a Super Admin?", one of which read a nested `admin.role.code` the contract never sent and
so refused *every* user including real Super Admins. Pages now get `identity` (id, name,
email, role *label*) and predicates. `role`, `permissions`, `is_active` and
`last_login_at` do not leave the auth layer. The rule this encodes: a page that needs a
new authorization question adds a predicate to the context; it does not get a field and
work the answer out itself.

**Role authorization moved to the route table.** `/operations` and `/research` refused
in-component, which meant the route table didn't tell the whole story, the same refusal
card existed twice in two different markups, and each page had to remember to skip its own
fetches after mounting. `<RequireRole>` never mounts the page, which deletes that last
class of mistake — the `ResearchWorkbench` fetch-skip is simply gone, not reimplemented.
Permission authorization deliberately stayed *out* of the route table: pages gate per
control, not per page.

**Permissions are constants, not literals.** ~45 literals across 16 files became `PERM`
members. The failure mode this fixes is silence: a typo'd literal returns false, hides a
control, and errors nowhere. All 33 seeded codes are listed, including the three the
console does not use (`settings.view`, `settings.manage`, `reports.export`), so the module
can be diffed against the backend seed in one read.

**One session shape.** `normalizeAdmin()` maps both the login payload and `/auth/me` onto
one internal shape with every field always present. The login response omits `role_name`,
so the derived label (`super_admin` → `Super Admin`) reproduces the backend's seeded name
for all five roles — which is what lets the sidebar show the real role without reading
differently before and after a page reload.

**Transient failures no longer log anyone out.** `refresh()` classified every failure as
an invalid session, so a backend restart or a dropped connection during boot cleared both
tokens. Now: **401/403 clear the session** (403 included — `get_current_admin` 403s a
deactivated account, so it is an authentication failure, and `/auth/me` carries no
permission requirement, so it can never be a permission failure). **Everything else keeps
the credentials** and `<RequireAuth>` renders a Retry / Sign-out panel instead of
redirecting.

**Dead exports integrated, not deleted.** `hasRole()` is now the primitive `<RequireRole>`
runs on. `roleLabel()` now feeds the sidebar sub-title, which had hard-coded "Super Admin"
for all five roles. `refresh()` now backs the Retry button. Nothing was left exported and
unused.

---

## 4. Deliberately unchanged

| Left alone | Why |
| --- | --- |
| Backend authorization | Out of scope, and it is the actual security boundary. Every frontend check here is convenience. |
| Tokens in `localStorage` | A real exposure (XSS yields both tokens) but a deliberate architectural trade-off. Changing it means `httpOnly` cookies, i.e. a backend change. |
| Two unauthenticated exits | `<RequireAuth>` preserves the deep link via `state.from`; the axios interceptor's `window.location.href = '/login'` on refresh failure does not. Unifying them means reaching the router from `api.js` — a transport/routing coupling worth more thought than a consistency pass should spend. |
| No client-side expiry | `expires_in` is still discarded and the JWT still never decoded. Adding proactive refresh or an idle timeout is a feature, not a consistency fix. |
| No multi-tab coordination | Same reasoning: a `storage` listener is new behaviour. |
| `/login` reachable while signed in | Cosmetic, and not in the pass's scope. |
| `/backtest` → retired router | The page 404s because its backend was retired in the V2 migration. A stale-surface issue, not an auth one. |
| Hidden vs. disabled *controls* | Brokers/Payments disable their switches while other pages hide buttons. In-page control affordance is a UI-design question; the pass standardised page-level and nav-level authorization, which is what "who can reach what" depends on. |
| Six pages with no client-side checks | `Users`, `UserActivity`, `UserCredits`, `UserPayments`, `AuditLog`, `Reports` are read-only listings whose endpoints are permission-guarded server-side. With nav now hiding what a role cannot open, they are unreachable by navigation for a role that lacks the permission. |
| `System` nav item has no `perm` | It is an unbuilt placeholder with no endpoints, so there is no permission it could honestly be gated on. `settings.view` is reserved for it. |
| No frontend tests | `frontend-admin/package.json` configures no test tooling. Standing this up is its own task. The pass's logic was verified by a throwaway harness (results below), not by committed tests. |

**One behaviour change beyond pure centralisation, called out:** the Credit Packages nav
item was gated on `subs.view`, but every `/admin/credit-packages` endpoint — including the
list — requires `subs.manage_plans`. Under the old "never hides" rule this was invisible;
under "you see what you can open" it would have shown a link that 403s on load. It is now
gated on `subs.manage_plans`. No permission was renamed. No seeded role is affected today
(`admin` and `finance_manager` both hold `subs.manage_plans`).

---

## 5. Verification

`npx vite build` — clean.

A throwaway harness exercised the pure logic, including `NAV`/`visibleNav` lifted from the
shipped `AdminLayout.jsx` source rather than re-implemented. 15 checks, all passing:

- login and `/me` produce identical key sets and the same role label
- derived role labels match the backend's seeded names for all five roles
- malformed payloads fail closed — including a nested `{role: {code}}`
- 401/403 destroy the session; network and 5xx failures preserve it
- per-role sidebars for `support_manager`, `finance_manager`, `content_manager`
- `superOnly` items hidden from every non-Super-Admin, even one holding `'*'`
- Credit Packages hidden for a `subs.view`-only role
- no section header ever survives without items

Static sweep: no `hasPermission('literal')` remains; every `PERM.X` referenced is defined;
every `PERM` constant name matches its code; no module outside `src/auth/` reads `role`,
`role_name`, `permissions`, `is_active` or `last_login_at` from the auth payload.
