# 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 `<form method="post" action="...">` inside.

## Implementation notes

- **Token creation modal**: render server-side after POST /admin/tokens succeeds. The page reloads with a `?just_created=<id>` 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.
