# M10 — UI: Admin CRUD Pages > Fresh Claude Code agent prompt. M09 must be complete and committed. > Estimated effort: large (lots of CRUD). ## Mission Add UI pages for every admin domain: Subnets (manual blocks), Allowlist, Policies (with the threshold matrix editor), Reporters, Consumers, Tokens (raw token shown once), Categories (with decay-curve preview). Wire manual block/allowlist actions into the IP detail page. **Audit log UI and Settings page are still M12.** ## Before you start 1. Verify M09: ```bash git log --oneline -9 cd api && composer test && cd .. cd ui && composer test && cd .. ``` 2. Read `SPEC.md` §6 (every Admin endpoint), §7 (every Page in the Pages section), §8 (RBAC matrix — confirm which roles can do what). 3. The api endpoints needed mostly exist already (M04 reporters/consumers/tokens, M06 manual_blocks/allowlist, M07 policies). The new ones this milestone adds are categories CRUD. ## Tasks ### 1. API: Categories admin endpoints In `api/src/Application/Admin/CategoriesController.php`: - `GET /api/v1/admin/categories` - `POST /api/v1/admin/categories` — body `{slug, name, description, decay_function: "linear"|"exponential", decay_param: number, is_active: bool}`. Slug must be unique, lowercase, kebab-case. - `PATCH /api/v1/admin/categories/{id}` - `DELETE /api/v1/admin/categories/{id}` — refuse if `policy_category_thresholds` references it OR `reports.category_id` references it (409 with usage info). Soft-delete via `is_active=false` is preferred when in use. RBAC: `Admin` for write, `Viewer` for read. ### 2. UI: AdminClient extensions Add methods for everything the new pages need: list/get/create/update/delete on policies, reporters, consumers, tokens, categories, manual_blocks, allowlist. Use the typed exceptions established in M08. ### 3. UI Pages Build under `ui/resources/views/pages/`: - `subnets/index.twig` — list of manual blocks with kind=`subnet`. Create form: CIDR input, reason, optional expiry. Single-IP manual blocks are still managed (use the same controller, separate "single IPs" tab if it helps clarity). - `manual-blocks/index.twig` — combined list (or two tabs: IPs and Subnets). Recommended: one page with a `kind` filter pill at the top. - `allowlist/index.twig` — same shape as manual-blocks but for allowlist entries. No expiry field. - `policies/index.twig` — list view. - `policies/edit.twig` — the threshold matrix editor: rows = categories, columns = ["threshold"]. Numeric input per category; "remove from policy" button to delete the threshold row. Below the matrix: a live "preview" panel that calls `GET /api/v1/admin/policies/{id}/preview` and shows the resulting count + first 50 entries. Debounce the preview (e.g., htmx + 500ms hx-trigger). - `reporters/index.twig`, `reporters/edit.twig` — list and edit. Show `trust_weight` prominently; a small explainer ("0–2.0; 1.0 default; affects how heavily this reporter influences scores"). - `consumers/index.twig`, `consumers/edit.twig` — list and edit. Show assigned policy with a dropdown. - `tokens/index.twig` — list of all non-service tokens. Columns: kind, prefix, target (reporter/consumer name or "admin role"), expires_at, revoked_at, last_used_at. Actions: revoke. Top of page: "+ New token" button → modal with kind selector and conditional fields. On creation: success modal showing the raw token in a monospace block, a copy-to-clipboard button, and a clear "this is the only time you'll see this token" warning. Modal must require explicit dismissal. - `categories/index.twig`, `categories/edit.twig` — list and edit. Edit page includes: - decay_function radio (linear / exponential). - decay_param numeric input with appropriate unit label ("days to zero" / "half-life days"). - Live preview chart: a small SVG (or Chart.js) showing the decay curve over 0–60 days. Pure client-side math; no API call. Must update reactively as the user changes inputs. ### 4. IP Detail action buttons On `ui/resources/views/pages/ips/detail.twig`, add (visible per RBAC): - "Add to allowlist" button (Operator+) — opens a modal with a reason field, POSTs to `/api/v1/admin/allowlist`. - "Manually block" button (Operator+) — opens a modal with a reason field and an optional expiry, POSTs to `/api/v1/admin/manual-blocks`. - If the IP is already manually blocked: "Remove manual block" button. - If the IP is on the allowlist: "Remove from allowlist" button. After any mutation, re-render the page (or do an htmx swap) so status reflects immediately. The CidrEvaluator from M06 should already invalidate; just make sure the api round-trip retrieves fresh data. ### 5. RBAC enforcement (UI side) - Hide buttons/links the user can't use. - Always treat UI hiding as cosmetic; the api enforces. Test that an Operator clicking through to a forbidden URL gets a friendly error page rather than an exception. ### 6. Sidebar updates Wire each section as a working link. Active highlighting per current section. ### 7. Confirmation modals All destructive actions (delete reporter, delete consumer, revoke token, delete category, delete policy, remove manual block, remove allowlist entry) require a confirmation modal. Modal pattern: small Twig partial reused everywhere, Alpine for show/hide, HTML `
` inside. ## Implementation notes - **Token creation modal**: render server-side after POST /admin/tokens succeeds. The page reloads with a `?just_created=` param; the page reads it once and shows the modal. Don't pass the raw token in the URL — store it in the flash session and clear after display. - **Policy threshold editor**: there are at most ~20 categories typically. A simple HTML table is fine. For each row: category slug + name, current threshold input, "remove" button, "add category to policy" select+button at the bottom. - **Decay curve preview**: a small Alpine component computes 60 sample points and renders them in an SVG path. ~30 lines of JS. Avoid pulling in a charting lib for this single curve. - **htmx for inline updates**: the threshold editor's preview pane is the prime use case. Other CRUD pages can be plain forms with full page reload — that's simpler and less buggy. - **Validation feedback**: when the api returns 400/422 with field errors, surface them inline on the form. The DTO/error mapping in `ApiClient` from M08 should already give you a `ValidationException` with field-level details. - **Tests**: - api: integration tests for the new categories endpoints. - ui: integration tests with mocked AdminClient verifying each CRUD page renders and submits correctly. Cover one happy path and one validation-error path per resource. ## Out of scope (DO NOT) - Audit log UI (M12). - Settings page (M12). - User management UI (`/admin/users`, role-mapping CRUD) — sketch in nav as a placeholder; the api endpoints exist but the UI pages are M12 alongside settings. Or: defer entirely. Pick one and document. - Bulk operations (multi-select delete, mass token revocation). Future work. - Inline editing on list pages (htmx cells). Edit pages are fine. - New api endpoints beyond categories CRUD. ## Acceptance ```bash cd api && composer cs && composer stan && composer test && cd .. cd ui && composer cs && composer stan && composer test && cd .. cd ui && npm run build && cd .. docker compose down -v cp .env.example .env docker compose up -d sleep 20 ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet) # Login as local admin COOKIE_JAR=$(mktemp) CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4) curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF&username=admin&password=test1234" \ http://localhost:8080/login/local -L > /dev/null # Each list page renders for p in subnets allowlist policies reporters consumers tokens categories; do curl -sf -b $COOKIE_JAR http://localhost:8080/app/$p > /dev/null done # Create a manual block via the UI CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/subnets | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1) curl -s -b $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF&kind=subnet&cidr=192.0.2.0/24&reason=test" \ http://localhost:8080/app/manual-blocks -L > /dev/null # Verify it persisted in the api curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" \ http://localhost:8081/api/v1/admin/manual-blocks | grep -q "192.0.2.0/24" # Operator role cannot delete a token (only admin can) docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet # (Manual: log out, log back in via OIDC as an operator-mapped user, verify token-delete button is absent # and that direct POST is rejected. For automated check, hit the api directly with an operator token.) OP_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet) TOKEN_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/tokens \ | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["items"][0]["id"];') test "$(curl -s -o /dev/null -w '%{http_code}' \ -X DELETE -H "Authorization: Bearer $OP_TOKEN" \ http://localhost:8081/api/v1/admin/tokens/$TOKEN_ID)" = "403" # Token creation modal flow (via the UI's session) CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/tokens | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1) RESP=$(curl -s -b $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF&kind=admin&role=viewer" \ http://localhost:8080/app/tokens -L) echo "$RESP" | grep -q "irdb_adm_" # raw token displayed in the response # Categories: create, then refuse delete because in use RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"slug":"phishing","name":"Phishing","description":"...","decay_function":"exponential","decay_param":14,"is_active":true}' \ http://localhost:8081/api/v1/admin/categories) CID=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];') # Add it to a policy PID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/policies \ | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["items"][0]["id"];') curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d "{\"thresholds\":{\"phishing\":1.0}}" http://localhost:8081/api/v1/admin/policies/$PID > /dev/null # Now delete should 409 test "$(curl -s -o /dev/null -w '%{http_code}' \ -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \ http://localhost:8081/api/v1/admin/categories/$CID)" = "409" docker compose down -v ``` ## Handoff 1. Commit: ``` feat(M10): UI admin CRUD; categories endpoints; IP detail actions - manual blocks, allowlist, policies (matrix editor), reporters, consumers, tokens, categories - token creation modal with one-time raw display + copy - decay-curve preview (svg) on category edit - manual-block / allowlist actions on IP detail page - api: CRUD for categories with in-use protection ``` 2. Append to `PROGRESS.md`: ```markdown ## M10 — UI admin CRUD (done) **Built:** every admin CRUD UI; categories endpoints; IP detail action buttons. **Notes for next milestone:** - User management UI (admin/users, role-mapping editor) is [either: built / deferred to M12]. Decide and note here. - Token list never includes service tokens (api enforces). - Operator vs Admin: operator can manage manual blocks and allowlist but not tokens, policies, categories, reporters, consumers, role mappings. **Deviations from SPEC:** none. **Added dependencies:** none. ``` 3. **Stop.** Do not start M11.