Fresh Claude Code agent prompt. M09 must be complete and committed. Estimated effort: large (lots of CRUD).
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.
Verify M09:
git log --oneline -9
cd api && composer test && cd ..
cd ui && composer test && cd ..
Read SPEC.md §6 (every Admin endpoint), §7 (every Page in the Pages section), §8 (RBAC matrix — confirm which roles can do what).
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.
In api/src/Application/Admin/CategoriesController.php:
GET /api/v1/admin/categoriesPOST /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.
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.
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:
On ui/resources/views/pages/ips/detail.twig, add (visible per RBAC):
/api/v1/admin/allowlist./api/v1/admin/manual-blocks.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.
Wire each section as a working link. Active highlighting per current section.
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.
?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.ApiClient from M08 should already give you a ValidationException with field-level details./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.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
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
Append to PROGRESS.md:
## 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.
Stop. Do not start M11.