# Module: `src/features/admin/AdminPage.tsx` > **Source**: `src/features/admin/AdminPage.tsx` (209 lines) > **Topo batch**: B4 (depends on B3: `api/client`, `components/ConfirmDialog`, `types/index`) ## 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. - **Bootstrap effect** (`useEffect([])` — runs once at mount): ```ts api.get('/api/annotations/classes').then(setClasses).catch(() => {}) api.get('/api/flights/aircrafts').then(setAircrafts).catch(() => {}) api.get('/api/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('/api/admin/classes', newClass)`. 3. Refetch via `api.get('/api/annotations/classes')` — note the **read** path is the public `annotations/` endpoint, while the **write** path is the `admin/` endpoint. 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('/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. - **`handleAddUser()`** — analogous to `handleAddClass` against `POST /api/admin/users` and `GET /api/admin/users`. Guards on `email && password`. - **`handleDeactivate()`** — fired from the ConfirmDialog confirm: 1. `PATCH /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 /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. - **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. - `` mounted at the end, controlled by `deactivateId`. ## Dependencies - **Internal**: - `../../api/client` — `api`. - `../../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`, `admin.aiSettings`, `admin.gpsSettings`, `admin.users`, `admin.aircrafts`, `admin.deactivate`, `common.save`. (Confirmed present in `src/i18n/en.json` admin/common groups.) 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 | Path | Purpose | |---|---|---| | `GET` | `/api/annotations/classes` | List detection classes (read path uses annotations service) | | `POST` | `/api/admin/classes` | Create detection class (write path uses admin service) | | `DELETE` | `/api/admin/classes/{id}` | Delete detection class | | `GET` | `/api/flights/aircrafts` | List aircraft | | `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` | | `GET` | `/api/admin/users` | List users | | `POST` | `/api/admin/users` | Create user | | `PATCH` | `/api/admin/users/{id}` | Set `isActive: false` (deactivate) | 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 / ``) 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 None. ## 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 `` 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.