1
0

M10-ui-admin-crud-pages.md 12 KB

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:

    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

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:

    ## 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.