Files
ui/_docs/02_document/modules/src__features__admin__AdminPage.md
T
Oleksandr Bezdieniezhnykh 873749197a
ci/woodpecker/push/build-arm Pipeline failed
[AZ-512] Cycle 4 Steps 12-15: test-spec sync + docs + sec + perf
Steps 12-15 closure for cycle 4 (AZ-512 admin class inline edit):

- Step 12 (Test-Spec Sync): traceability O9 -> Covered; new FT-P-62
  + FT-N-18 in blackbox-tests.md.
- Step 13 (Update Docs): AdminPage module doc gains the inline-edit
  state slots, four new handlers, PATCH integrations row, expanded
  i18n key list, tests section. architecture.md row 272 now lists
  PATCH /api/admin/classes/{id} with AZ-513 deploy-gate caveat.
- Step 14 (Security Audit): cycle-4 delta report records one new
  LOW finding (F-SAST-CY4-1 lost-update / mid-air-collision on
  PATCH, by design per spec); verdict carries PASS_WITH_WARNINGS;
  bun audit re-run clean.
- Step 15 (Performance Test): NFT-PERF-01 bundle = 291 332 B
  (+757 B / +0.26% vs cycle 3; ~13.89% of 2 MB budget); PASS.

Tests 243 passed / 13 skipped / 0 failed (+12 AZ-512 cases).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 04:51:17 +03:00

15 KiB
Raw Blame History

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

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):

    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.
    • ../../typesDetectionClass, 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 PATCHes. 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.