# Module: `src/features/admin/AdminPage.tsx`

> **Source**: `src/features/admin/AdminPage.tsx`
> **Topo batch**: B4 (depends on B3: `api/client`, `components/ConfirmDialog`, `types/index`)
> **Cycle 4 update (2026-05-13, AZ-512)**: gained an inline "edit detection class" affordance — see the new state slots, the `handleStartEdit / handleCancelEdit / handleUpdateClass / handleEditKeyDown` handlers, the PATCH row in the External integrations table, the new i18n keys consumed, and the FT-P-62 / FT-N-18 entries under Tests. Closes Architecture Vision principle **P12** (Objective O9 in `tests/traceability-matrix.md`). Implementation shipped against MSW stubs under the user-authorized Option B path; the live deploy gate remains until AZ-513 ships on the `admin/` workspace.

## Purpose

The administrator console of the SPA. Bundles four management surfaces
into a single page: detection-classes catalogue (CRUD-lite),
AI-recognition tuning form, GPS-device endpoint config, user
list (CRUD-lite), and aircraft default-selector. Replaces the WPF
`AdminWindow.xaml` cluster (`_docs/legacy/wpf-era.md` §§4, 5).

Behind the `/admin` route, gated by `Header`'s `ADM` permission
filter (a route-level permission re-check is the responsibility of the
`admin/` service — Header gating is UI-only; see Security below).

## Public interface

```ts
export default function AdminPage(): JSX.Element
```

No props. Reads everything via `api/client` and local state.

## Internal logic

- **State**:
  - `classes: DetectionClass[]` — the detection-class table.
  - `aircrafts: Aircraft[]` — the aircraft list with `isDefault` flag.
  - `users: User[]` — the user table.
  - `newClass: { name; shortName; color; maxSizeM }` — staging buffer
    for the "add detection class" form (initial: `{ name: '',
    shortName: '', color: '#FF0000', maxSizeM: 7 }`).
  - `newUser: { name; email; password; role }` — staging buffer for
    "add user" (initial: `{ name: '', email: '', password: '', role:
    'Annotator' }`).
  - `deactivateId: string | null` — drives the `ConfirmDialog`'s open
    state for user deactivation.
  - `editingId: number | null` — id of the detection class currently
    in inline-edit mode (AZ-512). A single value, not per-row, so
    opening one row's editor closes any other (AC-2 single-row
    invariant / Risk 3 mitigation).
  - `editForm: { name; shortName; color; maxSizeM }` — the inline-edit
    staging buffer; seeded from the row on edit-start.
  - `editError: 'nameRequired' | 'maxSizeMustBePositive' | 'updateFailed' | null` —
    discriminated error kind rendered as an inline `role="alert"`.
  - `editSaving: boolean` — disables Save + Cancel while the PATCH is
    in flight (Risk 4 mitigation).
- **Bootstrap effect** (`useEffect([])` — runs once at mount):

  ```ts
  api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
  api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
  api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
  ```

  Three independent calls, all silently swallowed on error. No retry,
  no error UI, no loading state — empty arrays render as empty
  tables. Flag for Step 4 against the user-feedback patterns in
  `_docs/ui_design/README.md`.
- **`handleAddClass()`**:
  1. Guard: `if (!newClass.name) return`.
  2. `await api.post(endpoints.admin.classes(), newClass)` (= `/api/admin/classes`).
  3. Refetch via `api.get(endpoints.annotations.classes())` — note the
     **read** path is the public `annotations/` endpoint
     (`/api/annotations/classes`), while the **write** path is the
     `admin/` endpoint (`/api/admin/classes`). Architectural caveat:
     two different services own the same logical entity. Document in
     `architecture.md` §integration-points (Step 3a).
  4. Reset `newClass` to its initial values.
  No error path — a failed POST throws (because `client.ts` throws on
  non-2xx); the throw is uncaught and reaches React's error boundary
  (none configured). Flag.
- **`handleDeleteClass(id)`**: optimistic local update —
  `await api.delete(endpoints.admin.class(id))` (= `/api/admin/classes/${id}`)
  then `setClasses(prev => prev.filter(c => c.id !== id))`. **No
  ConfirmDialog** despite this being destructive. Inconsistent with
  the user-deactivation flow which uses ConfirmDialog. Flag for Step 4
  against `_docs/ui_design/README.md` confirmation-dialog spec.
- **`handleStartEdit(c)`** (AZ-512): sets `editingId = c.id`, seeds
  `editForm` from `c`, clears `editError`. Triggered by the per-row
  pencil (✎) affordance.
- **`handleCancelEdit()`** (AZ-512): clears `editingId`, `editError`,
  `editSaving`. No network call. Also fires on **Escape** inside the
  form (AC-4).
- **`handleUpdateClass()`** (AZ-512):
  1. Guard: `editingId !== null && !editSaving`.
  2. Validation: `editForm.name.trim()` non-empty (else
     `setEditError('nameRequired')`); `editForm.maxSizeM > 0` (else
     `setEditError('maxSizeMustBePositive')`). Both pre-empt the
     network call (AC-5).
  3. `setEditSaving(true)`.
  4. `await api.patch(endpoints.admin.class(editingId), editForm)` —
     **the complete `editForm` is always sent** (Risk 2 mitigation:
     the backend's partial-merge vs full-replace semantics become
     equivalent for the UI).
  5. On success: `await api.get(endpoints.annotations.classes())`,
     `setClasses(...)`, `setEditingId(null)`.
  6. On failure: `setEditError('updateFailed')` — form stays open,
     edits intact, NO `alert()` (Finding B4 anti-pattern).
- **`handleEditKeyDown(e)`** (AZ-512): Enter → `handleUpdateClass`;
  Escape → `handleCancelEdit`. Wired at the container level so any
  input in the form respects it.
- **`handleAddUser()`** — analogous to `handleAddClass` against
  `POST endpoints.admin.users()` and `GET endpoints.admin.users()`
  (both → `/api/admin/users`). Guards on `email && password`.
- **`handleDeactivate()`** — fired from the ConfirmDialog confirm:
  1. `PATCH endpoints.admin.user(deactivateId)` (= `/api/admin/users/${deactivateId}`) with `{ isActive: false }`.
  2. Optimistic local update: marks the row inactive.
  3. Closes the dialog (`setDeactivateId(null)`).
  No "reactivate" path — once `isActive: false`, the row only renders
  the badge and no Deactivate button. Verify with `admin/` service:
  is reactivation an admin task or out of scope?
- **`handleToggleDefault(a)`** — `PATCH endpoints.flights.aircraft(a.id)`
  (= `/api/flights/aircrafts/${a.id}`) with `{ isDefault: !a.isDefault }`,
  then optimistic local flip. Note this allows multiple `isDefault:
  true` aircraft to coexist (the backend should enforce exclusivity;
  the UI does not).
- **Layout** (left → center → right, all in one horizontal flex):
  - **Left column** (`w-[340px]`): detection-classes table + add row.
    Each read-only row carries a pencil (✎) edit button and a `×`
    delete button (AZ-512). When `c.id === editingId`, that row's
    cells collapse into a single `colspan=3` form holding name /
    shortName / color / maxSizeM inputs + Save + Cancel (with an
    inline `role="alert"` directly below on validation/server error).
  - **Center column** (`flex-1 max-w-md`): AI settings form, GPS
    settings form, users table + add row. The AI and GPS forms have
    `defaultValue` only — there is **no** state, no `Save` handler
    wired up. The buttons render but do nothing. Flag.
  - **Right column** (`w-[280px]`): aircraft list with star toggle.
  - `<ConfirmDialog>` mounted at the end, controlled by `deactivateId`.

## Dependencies

- **Internal**:
  - `../../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7.)
  - `../../components/ConfirmDialog` — for user deactivation.
  - `../../types` — `DetectionClass`, `Aircraft`, `User`.
- **External**: `react` (`useState`, `useEffect`),
  `react-i18next` (`useTranslation`).

## Consumers (intra-repo)

- `src/App.tsx` — mounted at the `/admin` route inside the protected
  tree.

## Data models

Stateful local copies of `DetectionClass[]`, `Aircraft[]`, `User[]`.
The `newClass` and `newUser` buffers are anonymous shapes —
intentionally narrower than `DetectionClass` / `User` because the
backend assigns `id` and other server-managed fields.

## Configuration

- **i18n keys consumed**: `admin.classes.title` (was flat
  `admin.classes` pre-AZ-512), `admin.classes.edit`,
  `admin.classes.save`, `admin.classes.cancel`,
  `admin.classes.nameRequired`, `admin.classes.maxSizeMustBePositive`,
  `admin.classes.updateFailed`, `admin.aiSettings`,
  `admin.gpsSettings`, `admin.users`, `admin.aircrafts`,
  `admin.deactivate`, `common.save`. (Confirmed present in
  `src/i18n/en.json` admin/common groups; ua mirror enforced by the
  FT-P-22 parity gate.) Plenty of hardcoded
  English strings — placeholders ("Name", "Email", "Password"), table
  headers (`#`, `Name`, `Color`, `Email`, `Role`, `Status`), role
  options (`Annotator`, `Admin`, `Viewer`), the GPS protocol options
  (`TCP`, `UDP`), the AI tuning labels ("Frame Period Recognition",
  etc.), and the deactivation confirmation message. Step 4 candidate.
- **Hardcoded defaults**:
  - `maxSizeM: 7` for new detection classes.
  - `color: '#FF0000'` for new detection classes.
  - `Frame Period Recognition: 5`, `Frame Recognition Seconds: 1`,
    `Probability Threshold: 0.5` (`step: 0.05`, `min: 0`, `max: 1`).
  - `Device Address: 192.168.1.100`, `Port: 5535`, default protocol
    `TCP`. **Hardcoded internal IP** is a smell; should come from
    `system_settings` or be a placeholder. Flag for Step 4 / Step 6
    (`security_approach.md`).
- **Tailwind tokens**: `bg-az-panel`, `bg-az-bg`, `bg-az-orange`,
  `bg-az-blue/20`, `bg-az-green/20`, `text-az-{text,muted,red,green,
  blue,orange}`, `border-az-border`. Defined in `src/index.css`.

## External integrations

| Method | Builder → Path | Purpose |
|---|---|---|
| `GET` | `endpoints.annotations.classes()` → `/api/annotations/classes` | List detection classes (read path uses annotations service) |
| `POST` | `endpoints.admin.classes()` → `/api/admin/classes` | Create detection class (write path uses admin service) |
| `PATCH` | `endpoints.admin.class(id)` → `/api/admin/classes/{id}` | Update detection class (AZ-512 — full body always sent; same URL as DELETE, no new endpoint helper introduced per task constraint) |
| `DELETE` | `endpoints.admin.class(id)` → `/api/admin/classes/{id}` | Delete detection class |
| `GET` | `endpoints.flights.aircrafts()` → `/api/flights/aircrafts` | List aircraft |
| `PATCH` | `endpoints.flights.aircraft(id)` → `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
| `GET` | `endpoints.admin.users()` → `/api/admin/users` | List users |
| `POST` | `endpoints.admin.users()` → `/api/admin/users` | Create user |
| `PATCH` | `endpoints.admin.user(id)` → `/api/admin/users/{id}` | Set `isActive: false` (deactivate) |

Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7). Routed by `nginx.conf` to `admin/`, `annotations/`, `flights/` backends.

## Security

- **`/admin` is access-controlled by Header's `ADM` permission filter
  AND by every backend endpoint** (each `/api/admin/*` is the
  authority). The page itself does not call `hasPermission` — a user
  who deep-links to `/admin` would render the page but every fetch
  would 401/403. Acceptable for production; flag a UX improvement
  (server-error → friendly redirect) for Step 8.
- **Password input has `type="password"`** — masked. The new-user
  password is held in plaintext component state until the POST
  resolves. Acceptable; cleared by `setNewUser({...})` after success.
- **`color: '#FF0000'`** is a hex string — when posted to
  `/api/admin/classes`, the backend must validate. UI-side validation
  (regex / `<input type="color">`) is enforced because the input is
  a color-picker.
- **No CSRF** — relies on the bearer-token scheme. Same posture as the
  rest of the SPA (see `client.ts` Security section).
- **No row-level access checks visible to the UI**: the user list
  shows every row the backend returns; if the `admin/` service ever
  filtered by tenant, the UI would not need changes.

## Tests

- `tests/admin_class_edit.test.tsx` (cycle 4, AZ-512) — 12 cases
  covering AC-1 through AC-6 + AC-8; AC-7 covered by the static
  FT-P-22 i18n parity gate. Traces to FT-P-62 + FT-N-18 in
  `_docs/02_document/tests/blackbox-tests.md`.
- `tests/destructive_ux.test.tsx` (cycle 1) — AZ-466 class-delete
  destructive-UX `it.fails()` + control pair. Updated cycle 4 to
  target the `×` delete button by text after the AZ-512 ✎ button
  was added to the same row's action cell.

No dedicated `AdminPage` happy-path test predates AZ-512; the AC-8
regression guard in `admin_class_edit.test.tsx` covers Add and
Delete inline. A broader AdminPage test fixture is a Phase B
candidate.

## Notes / open questions

- **Detection-class read/write split** between `annotations/` and
  `admin/` is unusual. Verify with the suite ADRs whether
  `/api/annotations/classes` and `/api/admin/classes` are the same
  underlying entity. If yes, the split is a legacy artifact; if no,
  the UI is conflating two collections. Step 3a / Step 4 verification.
- **AI settings & GPS settings forms are wired to nothing** — every
  `<input>` uses `defaultValue` (uncontrolled), there is no submit
  handler, and the Save button does nothing. Either:
  - The legacy WPF AI/GPS settings have not been ported yet (most
    likely — `_docs/legacy/wpf-era.md` §"What is intentionally NOT
    being ported" might apply), OR
  - The backend endpoint exists but the wiring was lost during the
    port.
  Flag prominently in Step 4 problem-extraction. Probably a missing
  feature, not a bug.
- **`handleDeleteClass` has no confirmation** — UX inconsistency with
  user deactivation. Consider unifying via `ConfirmDialog`. Step 4.
- **Aircraft default-toggle race**: clicking two aircraft in quick
  succession will fire two parallel `PATCH`es. The backend may end up
  with both `isDefault: true` if it does not enforce exclusivity at
  the persistence layer. Flag for Step 6 problem-extraction. Trust
  the backend; do not add UI debouncing.
- **The "Active" / "Inactive" badge text is hardcoded English** even
  though the surrounding column header (`Status`) is too. Either
  localize all of them or accept the inconsistency. Step 4.
- **`u.isActive` as the only mutable user field** — no rename, no
  password reset, no role change. Document the gap; may be a feature
  cycle item for Phase B.
- **`newClass.shortName` is collected but not displayed in the table**
  — the table only shows `id, name, color, ×`. The `shortName` lives
  only in the staging buffer and is sent to the backend. Verify the
  backend stores it.
- **Hardcoded GPS device address `192.168.1.100`** is a non-routable
  RFC1918 default that should not ship with the production bundle.
  Step 4 flag.
