Explorar el Código

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 (409 + usage payload)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa hace 1 semana
padre
commit
cd16c0f21f
Se han modificado 34 ficheros con 3831 adiciones y 18 borrados
  1. 38 0
      PROGRESS.md
  2. 15 0
      api/src/App/AppFactory.php
  3. 2 0
      api/src/App/Container.php
  4. 258 0
      api/src/Application/Admin/CategoriesController.php
  5. 16 0
      api/src/Domain/Category/Category.php
  6. 77 2
      api/src/Infrastructure/Category/CategoryRepository.php
  7. 202 0
      api/tests/Integration/Admin/CategoriesControllerTest.php
  8. 95 0
      ui/resources/views/pages/allowlist/index.twig
  9. 105 0
      ui/resources/views/pages/categories/edit.twig
  10. 95 0
      ui/resources/views/pages/categories/index.twig
  11. 46 0
      ui/resources/views/pages/consumers/edit.twig
  12. 93 0
      ui/resources/views/pages/consumers/index.twig
  13. 58 0
      ui/resources/views/pages/ips/detail.twig
  14. 106 0
      ui/resources/views/pages/manual-blocks/index.twig
  15. 138 0
      ui/resources/views/pages/policies/edit.twig
  16. 74 0
      ui/resources/views/pages/policies/index.twig
  17. 43 0
      ui/resources/views/pages/reporters/edit.twig
  18. 84 0
      ui/resources/views/pages/reporters/index.twig
  19. 140 0
      ui/resources/views/pages/tokens/index.twig
  20. 37 0
      ui/resources/views/partials/confirm_form.twig
  21. 13 12
      ui/resources/views/partials/sidebar.twig
  22. 268 4
      ui/src/ApiClient/AdminClient.php
  23. 62 0
      ui/src/App/AppFactory.php
  24. 14 0
      ui/src/App/Container.php
  25. 130 0
      ui/src/Controllers/AllowlistController.php
  26. 213 0
      ui/src/Controllers/CategoriesController.php
  27. 206 0
      ui/src/Controllers/ConsumersController.php
  28. 116 0
      ui/src/Controllers/CrudControllerSupport.php
  29. 1 0
      ui/src/Controllers/IpsController.php
  30. 165 0
      ui/src/Controllers/ManualBlocksController.php
  31. 269 0
      ui/src/Controllers/PoliciesController.php
  32. 199 0
      ui/src/Controllers/ReportersController.php
  33. 149 0
      ui/src/Controllers/TokensController.php
  34. 304 0
      ui/tests/Integration/Crud/CrudPagesTest.php

+ 38 - 0
PROGRESS.md

@@ -231,3 +231,41 @@
 
 **Added dependencies:**
 - `chart.js` (npm dep). The SPEC's M09 doc explicitly allows it; tree-shaken to bar/linear/category controllers + element + tooltip + title via Chart.js's modular registration, keeping the impact at ~150kb of the final ~263kb bundle (Chart.js + Alpine + htmx + our own ~3kb).
+
+## M10 — UI admin CRUD (done)
+
+**Built:** every admin CRUD UI (manual blocks, allowlist, policies with threshold matrix, reporters, consumers, tokens with one-time raw-token modal, categories with SVG decay-curve preview), plus IP-detail action buttons (allowlist/manual-block add+remove). Categories CRUD on the api with in-use refusal.
+
+**API surface added:** `GET/POST/PATCH/DELETE /api/v1/admin/categories`. Slug is kebab-ish (`^[a-z][a-z0-9_]{0,63}$`) + unique. DELETE returns 409 with `{usage: {policies, reports}, hint}` when references exist; the UI surfaces the hint via flash, and the edit page exposes an `is_active=false` checkbox for soft-delete.
+
+**Notes for next milestone:**
+- `AdminClient` covers every admin endpoint shipped so far. M11 only needs to extend it for enrichment data shape; M12 adds audit-log + jobs trigger.
+- User management UI (`/app/users`, role-mapping editor) — **deferred to M12** alongside the audit page; the api endpoints exist but no UI was built. Sidebar still hides the link until then.
+- Token list never includes service tokens (the api filters them out unconditionally; the bootstrap is the only producer).
+- RBAC summary applied this milestone: viewer reads everything; operator may manage manual blocks + allowlist; admin owns tokens, policies, categories, reporters, consumers. UI hides buttons for roles that can't use them; the api enforces the actual gate. Friendly error pages render on direct-URL forbidden access via the existing JsonExceptionHandler.
+- Token creation flow uses a session slot (`_token_just_created`) instead of a query string — POST → 303 → GET reads-and-clears the slot, so refreshing the page after the modal is dismissed never re-shows the raw token (one-time-display invariant).
+- Policy threshold editor renders rows for all categories; empty input means "not in policy" (not "threshold = 0"). The PATCH endpoint replaces the threshold set wholesale, so unchecked rows simply don't appear in the body.
+- Decay-curve preview is pure client-side Alpine (~30 lines) — `Decay::value` ported to JS. No charting lib for one curve.
+- Live policy preview calls a UI-side proxy (`/app/policies/{id}/preview-proxy`) rather than hitting the api directly from the browser; the browser doesn't have the service token, so the proxy bridges the BFF gap. Returns the api's preview JSON verbatim.
+- POST handlers consistently return **303 See Other** so curl/browsers follow with GET (M08 lesson). Form-encoded `next` field threads through delete actions on the IP detail page so the user lands back on the IP they were viewing rather than the manual-blocks list.
+- All destructive actions go through a small `partials/confirm_form.twig` Alpine modal — single source of truth for the cancel/confirm UX. Reused by every list page.
+- Decision: `/app/manual-blocks` POST always redirects back to `/app/manual-blocks` (never to `/app/subnets`), even when the created entry is a subnet. Stable destination keeps tests + browser behaviour predictable; the user can navigate via the kind filter.
+
+**Schema:** none.
+
+**Test surface added (api):** `tests/Integration/Admin/CategoriesControllerTest.php` (9 tests covering list, validation, create+show, duplicate-slug refusal, PATCH, in-use 409 from policy refs, in-use 409 from report refs, hard-delete success, RBAC). Total: 294 tests / 821 assertions.
+
+**Test surface added (ui):** `tests/Integration/Crud/CrudPagesTest.php` (16 tests: each list page renders + a happy/validation pair per resource + the token one-time-display flow + IP-detail RBAC button visibility for operator vs viewer). Total: 71 tests / 177 assertions.
+
+**Acceptance script:** ran end-to-end against compose stack:
+- All seven list pages return 200 for a logged-in admin.
+- POST /app/manual-blocks creates the subnet (verified by the api list endpoint; the milestone-doc grep used a non-escaped `/` and got tripped by JSON's `\/` escaping — data was actually present).
+- Operator-role admin token gets 403 on `DELETE /api/v1/admin/tokens/{id}` (api enforces).
+- Token-creation modal flow: POST returns 303, follow-up GET surfaces the `irdb_adm_<32 base32>` raw token in the page body and discards the slot afterward.
+- Categories: created `phishing`, attached to a policy, then DELETE returns 409 (api refuses with `category_in_use`).
+
+**Deviations from SPEC:**
+- User management UI (admin/users + role-mapping editor) deferred to M12 alongside the audit page. Documented under "Notes for next milestone" so M12 owners pick it up.
+- `IpScoreRepository::final` was already dropped in M09 to allow a test stub; no further class-modifier changes this milestone.
+
+**Added dependencies:** none.

+ 15 - 0
api/src/App/AppFactory.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\App;
 
 use App\Application\Admin\AllowlistController;
+use App\Application\Admin\CategoriesController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\IpsController;
 use App\Application\Admin\ManualBlocksController;
@@ -194,6 +195,20 @@ final class AppFactory
             $admin->get('/stats/dashboard', [$stats, 'dashboard'])
                 ->add(RbacMiddleware::require($rf, Role::Viewer));
 
+            // Categories: list/show = Viewer; write = Admin (per SPEC §M10.1).
+            /** @var CategoriesController $categories */
+            $categories = $container->get(CategoriesController::class);
+            $admin->get('/categories', [$categories, 'list'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->get('/categories/{id}', [$categories, 'show'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->post('/categories', [$categories, 'create'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+            $admin->patch('/categories/{id}', [$categories, 'update'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+            $admin->delete('/categories/{id}', [$categories, 'delete'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+
             // Policies: list/show/preview = Viewer; write = Admin.
             /** @var PoliciesController $policies */
             $policies = $container->get(PoliciesController::class);

+ 2 - 0
api/src/App/Container.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\App;
 
 use App\Application\Admin\AllowlistController;
+use App\Application\Admin\CategoriesController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\IpsController;
 use App\Application\Admin\ManualBlocksController;
@@ -308,6 +309,7 @@ final class Container
             BlocklistController::class => autowire(),
             IpsController::class => autowire(),
             StatsController::class => autowire(),
+            CategoriesController::class => autowire(),
         ]);
 
         return $builder->build();

+ 258 - 0
api/src/Application/Admin/CategoriesController.php

@@ -0,0 +1,258 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Reputation\DecayFunction;
+use App\Infrastructure\Category\CategoryRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin CRUD over `categories`. Slugs are kebab-case lowercase + unique;
+ * decay function is `linear|exponential`; decay param is non-negative.
+ *
+ * Delete semantics (SPEC §M10.1):
+ *  - if the category is referenced by `policy_category_thresholds` OR
+ *    `reports`, refuse with 409 + a `usage` payload describing the
+ *    references. Soft-delete via `is_active=false` is the recommended
+ *    fallback (do that explicitly via PATCH).
+ *  - if no references exist, hard-delete.
+ *
+ * RBAC: list/show ⇒ Viewer; create/update/delete ⇒ Admin.
+ */
+final class CategoriesController
+{
+    use AdminControllerSupport;
+
+    private const SLUG_PATTERN = '/^[a-z][a-z0-9_]{0,63}$/';
+
+    public function __construct(private readonly CategoryRepository $categories)
+    {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $items = array_map(static fn ($c) => $c->toArray(), $this->categories->listAll());
+
+        return self::json($response, 200, [
+            'items' => $items,
+            'total' => count($items),
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $category = $this->categories->findById($id);
+        if ($category === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json($response, 200, $category->toArray());
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $slug = isset($body['slug']) && is_string($body['slug']) ? trim($body['slug']) : '';
+        if ($slug === '' || preg_match(self::SLUG_PATTERN, $slug) !== 1) {
+            $errors['slug'] = 'required, lowercase alpha + digits + underscore, ≤64 chars';
+        } elseif ($this->categories->findBySlug($slug) !== null) {
+            $errors['slug'] = 'already exists';
+        }
+
+        $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '';
+        if ($name === '' || strlen($name) > 128) {
+            $errors['name'] = 'required, 1–128 chars';
+        }
+
+        $description = null;
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $description = $body['description'];
+            }
+        }
+
+        $decayFunction = null;
+        $rawFn = $body['decay_function'] ?? null;
+        if (is_string($rawFn)) {
+            $decayFunction = DecayFunction::tryFrom($rawFn);
+        }
+        if ($decayFunction === null) {
+            $errors['decay_function'] = 'required, "linear" or "exponential"';
+        }
+
+        $decayParam = null;
+        if (isset($body['decay_param']) && (is_int($body['decay_param']) || is_float($body['decay_param']))) {
+            $decayParam = (float) $body['decay_param'];
+            if ($decayParam <= 0) {
+                $errors['decay_param'] = 'must be positive';
+            }
+        } else {
+            $errors['decay_param'] = 'required, positive number';
+        }
+
+        $isActive = true;
+        if (array_key_exists('is_active', $body)) {
+            if (!is_bool($body['is_active'])) {
+                $errors['is_active'] = 'must be boolean';
+            } else {
+                $isActive = $body['is_active'];
+            }
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        /** @var DecayFunction $decayFunction */
+        /** @var float $decayParam */
+        $id = $this->categories->create($slug, $name, $description, $decayFunction, $decayParam, $isActive);
+        $created = $this->categories->findById($id);
+        if ($created === null) {
+            return self::error($response, 500, 'create_failed');
+        }
+
+        return self::json($response, 201, $created->toArray());
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->categories->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $body = self::jsonBody($request);
+        $errors = [];
+        $fields = [];
+
+        if (array_key_exists('slug', $body)) {
+            if (!is_string($body['slug']) || preg_match(self::SLUG_PATTERN, trim($body['slug'])) !== 1) {
+                $errors['slug'] = 'lowercase alpha + digits + underscore, ≤64 chars';
+            } else {
+                $newSlug = trim($body['slug']);
+                $other = $this->categories->findBySlug($newSlug);
+                if ($other !== null && $other->id !== $id) {
+                    $errors['slug'] = 'already exists';
+                } else {
+                    $fields['slug'] = $newSlug;
+                }
+            }
+        }
+
+        if (array_key_exists('name', $body)) {
+            if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) {
+                $errors['name'] = 'required, 1–128 chars';
+            } else {
+                $fields['name'] = trim($body['name']);
+            }
+        }
+
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $fields['description'] = $body['description'];
+            }
+        }
+
+        if (array_key_exists('decay_function', $body)) {
+            $fn = is_string($body['decay_function']) ? DecayFunction::tryFrom($body['decay_function']) : null;
+            if ($fn === null) {
+                $errors['decay_function'] = '"linear" or "exponential"';
+            } else {
+                $fields['decay_function'] = $fn->value;
+            }
+        }
+
+        if (array_key_exists('decay_param', $body)) {
+            if (is_int($body['decay_param']) || is_float($body['decay_param'])) {
+                $value = (float) $body['decay_param'];
+                if ($value <= 0) {
+                    $errors['decay_param'] = 'must be positive';
+                } else {
+                    $fields['decay_param'] = number_format($value, 4, '.', '');
+                }
+            } else {
+                $errors['decay_param'] = 'must be positive number';
+            }
+        }
+
+        if (array_key_exists('is_active', $body)) {
+            if (!is_bool($body['is_active'])) {
+                $errors['is_active'] = 'must be boolean';
+            } else {
+                $fields['is_active'] = $body['is_active'] ? 1 : 0;
+            }
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $this->categories->update($id, $fields);
+        $updated = $this->categories->findById($id);
+        if ($updated === null) {
+            return self::error($response, 500, 'update_failed');
+        }
+
+        return self::json($response, 200, $updated->toArray());
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->categories->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $policyRefs = $this->categories->policyReferenceCount($id);
+        $reportRefs = $this->categories->reportReferenceCount($id);
+        if ($policyRefs > 0 || $reportRefs > 0) {
+            return self::json($response, 409, [
+                'error' => 'category_in_use',
+                'usage' => [
+                    'policies' => $policyRefs,
+                    'reports' => $reportRefs,
+                ],
+                'hint' => 'PATCH with is_active=false to soft-delete instead.',
+            ]);
+        }
+
+        $this->categories->delete($id);
+
+        return $response->withStatus(204);
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+}

+ 16 - 0
api/src/Domain/Category/Category.php

@@ -22,4 +22,20 @@ final class Category
         public readonly bool $isActive,
     ) {
     }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function toArray(): array
+    {
+        return [
+            'id' => $this->id,
+            'slug' => $this->slug,
+            'name' => $this->name,
+            'description' => $this->description,
+            'decay_function' => $this->decayFunction->value,
+            'decay_param' => $this->decayParam,
+            'is_active' => $this->isActive,
+        ];
+    }
 }

+ 77 - 2
api/src/Infrastructure/Category/CategoryRepository.php

@@ -9,10 +9,11 @@ use App\Domain\Reputation\DecayFunction;
 use Doctrine\DBAL\Connection;
 
 /**
- * Read-mostly gateway for the categories table.
+ * DBAL gateway for the `categories` table.
  *
  * Ingest looks up by `slug`; the PairScorer needs the decay function and
- * parameter while computing a score. CRUD on categories is M07's job.
+ * parameter while computing a score; M10 adds CRUD on top so admins can
+ * manage categories from the UI.
  */
 final class CategoryRepository
 {
@@ -32,6 +33,18 @@ final class CategoryRepository
         return $row === false ? null : self::hydrate($row);
     }
 
+    public function findBySlug(string $slug): ?Category
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, slug, name, description, decay_function, decay_param, is_active '
+            . 'FROM categories WHERE slug = :slug',
+            ['slug' => $slug]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
     public function findById(int $id): ?Category
     {
         /** @var array<string, mixed>|false $row */
@@ -58,6 +71,68 @@ final class CategoryRepository
         return array_map(static fn (array $r): Category => self::hydrate($r), $rows);
     }
 
+    public function create(
+        string $slug,
+        string $name,
+        ?string $description,
+        DecayFunction $decayFunction,
+        float $decayParam,
+        bool $isActive,
+    ): int {
+        $this->connection->insert('categories', [
+            'slug' => $slug,
+            'name' => $name,
+            'description' => $description,
+            'decay_function' => $decayFunction->value,
+            'decay_param' => number_format($decayParam, 4, '.', ''),
+            'is_active' => $isActive ? 1 : 0,
+        ]);
+
+        return (int) $this->connection->lastInsertId();
+    }
+
+    /**
+     * @param array<string, mixed> $fields
+     */
+    public function update(int $id, array $fields): void
+    {
+        if ($fields === []) {
+            return;
+        }
+        $this->connection->update('categories', $fields, ['id' => $id]);
+    }
+
+    public function delete(int $id): void
+    {
+        $this->connection->delete('categories', ['id' => $id]);
+    }
+
+    /**
+     * Returns the count of `policy_category_thresholds` rows referencing
+     * this category. Non-zero ⇒ the category is "in use" from the
+     * policy side.
+     */
+    public function policyReferenceCount(int $categoryId): int
+    {
+        return (int) $this->connection->fetchOne(
+            'SELECT COUNT(*) FROM policy_category_thresholds WHERE category_id = :id',
+            ['id' => $categoryId]
+        );
+    }
+
+    /**
+     * Returns the count of `reports` rows referencing this category.
+     * Non-zero ⇒ the category is "in use" from the historical-reports
+     * side.
+     */
+    public function reportReferenceCount(int $categoryId): int
+    {
+        return (int) $this->connection->fetchOne(
+            'SELECT COUNT(*) FROM reports WHERE category_id = :id',
+            ['id' => $categoryId]
+        );
+    }
+
     /**
      * @param array<string, mixed> $row
      */

+ 202 - 0
api/tests/Integration/Admin/CategoriesControllerTest.php

@@ -0,0 +1,202 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\IpAddress;
+use App\Tests\Integration\Support\AppTestCase;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Covers SPEC §M10.1: categories CRUD + in-use refusal on delete.
+ */
+final class CategoriesControllerTest extends AppTestCase
+{
+    public function testListReturnsSeededCategories(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/categories', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertGreaterThanOrEqual(5, $body['total']);
+        $slugs = array_map(static fn (array $r): string => $r['slug'], $body['items']);
+        self::assertContains('brute_force', $slugs);
+    }
+
+    public function testCreateRejectsBadSlug(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode([
+                'slug' => 'BAD-Slug',
+                'name' => 'X',
+                'decay_function' => 'exponential',
+                'decay_param' => 14,
+            ]),
+        );
+        self::assertSame(400, $response->getStatusCode());
+        $details = $this->decode($response)['details'];
+        self::assertArrayHasKey('slug', $details);
+    }
+
+    public function testCreateAndShow(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode([
+                'slug' => 'phishing',
+                'name' => 'Phishing',
+                'description' => 'fake-domain reports',
+                'decay_function' => 'exponential',
+                'decay_param' => 14,
+                'is_active' => true,
+            ]),
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $created = $this->decode($response);
+        self::assertSame('phishing', $created['slug']);
+
+        $get = $this->request('GET', '/api/v1/admin/categories/' . $created['id'], [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $get->getStatusCode());
+        self::assertSame('phishing', $this->decode($get)['slug']);
+    }
+
+    public function testCreateRejectsDuplicateSlug(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode([
+                'slug' => 'brute_force', // already seeded
+                'name' => 'Dup',
+                'decay_function' => 'linear',
+                'decay_param' => 30,
+            ]),
+        );
+        self::assertSame(400, $response->getStatusCode());
+        self::assertArrayHasKey('slug', $this->decode($response)['details']);
+    }
+
+    public function testPatchUpdatesFields(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'spam']);
+
+        $response = $this->request(
+            'PATCH',
+            '/api/v1/admin/categories/' . $catId,
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode(['name' => 'Spam (renamed)', 'is_active' => false]),
+        );
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('Spam (renamed)', $body['name']);
+        self::assertFalse($body['is_active']);
+    }
+
+    public function testDeleteRefusedWhenReferencedByPolicy(): void
+    {
+        // Seeded policies reference all five seeded categories, so any
+        // seeded category will trip the in-use guard.
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+
+        $response = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(409, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('category_in_use', $body['error']);
+        self::assertGreaterThan(0, $body['usage']['policies']);
+    }
+
+    public function testDeleteRefusedWhenReferencedByReports(): void
+    {
+        // Create a fresh category, attach a single report, then try to
+        // delete it. Policy refs are zero (we didn't add it to any
+        // policy); only the report ref blocks the delete.
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $createResp = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode([
+                'slug' => 'tmp_only', 'name' => 'Tmp',
+                'decay_function' => 'linear', 'decay_param' => 5,
+            ]),
+        );
+        $catId = (int) $this->decode($createResp)['id'];
+        $reporterId = $this->createReporter('rep-cat-test');
+
+        $stmt = $this->db->prepare(
+            'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at) '
+            . 'VALUES (:b, :t, :c, :r, :w, :now)'
+        );
+        $stmt->bindValue('b', IpAddress::fromString('203.0.113.5')->binary(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('t', '203.0.113.5');
+        $stmt->bindValue('c', $catId, ParameterType::INTEGER);
+        $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
+        $stmt->bindValue('w', '1.00');
+        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
+        $stmt->executeStatement();
+
+        $delete = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(409, $delete->getStatusCode());
+        $body = $this->decode($delete);
+        self::assertSame('category_in_use', $body['error']);
+        self::assertSame(0, $body['usage']['policies']);
+        self::assertGreaterThan(0, $body['usage']['reports']);
+    }
+
+    public function testDeleteSucceedsWhenNotInUse(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $createResp = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode([
+                'slug' => 'orphan', 'name' => 'Orphan',
+                'decay_function' => 'linear', 'decay_param' => 5,
+            ]),
+        );
+        $catId = (int) $this->decode($createResp)['id'];
+
+        $delete = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(204, $delete->getStatusCode());
+    }
+
+    public function testNonAdminCannotCreate(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode([
+                'slug' => 'x', 'name' => 'X',
+                'decay_function' => 'linear', 'decay_param' => 5,
+            ]),
+        );
+        self::assertSame(403, $response->getStatusCode());
+    }
+}

+ 95 - 0
ui/resources/views/pages/allowlist/index.twig

@@ -0,0 +1,95 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Allowlist — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Allowlist</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+
+    {% set kind_links = [
+        { label: 'All',     value: '' },
+        { label: 'IPs',     value: 'ip' },
+        { label: 'Subnets', value: 'subnet' },
+    ] %}
+    <div class="mt-4 flex gap-2 text-sm">
+        {% for k in kind_links %}
+            {% set is_active = (kind|default('') == k.value) or (kind == null and k.value == '') %}
+            <a href="/app/allowlist{% if k.value %}?kind={{ k.value }}{% endif %}"
+               class="rounded-full px-3 py-1 {% if is_active %}bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100{% else %}border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800{% endif %}">{{ k.label }}</a>
+        {% endfor %}
+    </div>
+
+    {% if can_write %}
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Add allowlist entry</h2>
+            <form method="post" action="/app/allowlist" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm" x-data="{ kind: 'ip' }">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                <div>
+                    <label for="al-kind" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Kind</label>
+                    <select id="al-kind" name="kind" x-model="kind"
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="ip">Single IP</option>
+                        <option value="subnet">Subnet (CIDR)</option>
+                    </select>
+                </div>
+                <div x-show="kind == 'ip'">
+                    <label for="al-ip" class="block text-xs font-medium text-slate-600 dark:text-slate-400">IP</label>
+                    <input type="text" id="al-ip" name="ip" placeholder="203.0.113.5"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div x-show="kind == 'subnet'">
+                    <label for="al-cidr" class="block text-xs font-medium text-slate-600 dark:text-slate-400">CIDR</label>
+                    <input type="text" id="al-cidr" name="cidr" placeholder="10.0.0.0/8"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div>
+                    <label for="al-reason" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Reason</label>
+                    <input type="text" id="al-reason" name="reason"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-3 flex justify-end">
+                    <button type="submit" class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500">Add entry</button>
+                </div>
+            </form>
+        </section>
+    {% endif %}
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">Kind</th>
+                    <th class="px-4 py-2 font-medium">Target</th>
+                    <th class="px-4 py-2 font-medium">Reason</th>
+                    <th class="px-4 py-2 font-medium">Created</th>
+                    {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for item in list.items|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
+                        <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                        {% if can_write %}
+                            <td class="px-4 py-2 text-right">
+                                {% include 'partials/confirm_form.twig' with {
+                                    action: '/app/allowlist/' ~ item.id ~ '/delete',
+                                    label: 'Remove',
+                                    description: 'This removes the allowlist entry immediately.',
+                                } only %}
+                            </td>
+                        {% endif %}
+                    </tr>
+                {% else %}
+                    <tr><td colspan="5" class="px-4 py-6 text-center text-slate-400">No allowlist entries.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+{% endblock %}

+ 105 - 0
ui/resources/views/pages/categories/edit.twig

@@ -0,0 +1,105 @@
+{% extends 'layout.twig' %}
+
+{% block title %}{{ category.slug }} — Category — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-3xl"
+     x-data="decayPreview({
+        fn: '{{ category.decay_function }}',
+        param: {{ category.decay_param }},
+     })">
+    <a href="/app/categories" class="text-sm text-slate-500 hover:underline dark:text-slate-400">← Back to categories</a>
+    <h1 class="mt-3 text-2xl font-semibold tracking-tight font-mono">{{ category.slug }}</h1>
+
+    <form method="post" action="/app/categories/{{ category.id }}" class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <div class="grid grid-cols-1 gap-3 md:grid-cols-2 text-sm">
+            <div>
+                <label for="cat-slug" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Slug</label>
+                <input type="text" id="cat-slug" name="slug" value="{{ category.slug }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div>
+                <label for="cat-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                <input type="text" id="cat-name" name="name" value="{{ category.name }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div class="md:col-span-2">
+                <label for="cat-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                <input type="text" id="cat-desc" name="description" value="{{ category.description|default('') }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div>
+                <label class="block text-xs font-medium text-slate-600 dark:text-slate-400">Decay function</label>
+                <div class="mt-1 space-y-1">
+                    <label class="flex items-center gap-2 text-sm">
+                        <input type="radio" name="decay_function" value="linear" x-model="fn" {% if not can_write %}disabled{% endif %}>
+                        linear (days to zero)
+                    </label>
+                    <label class="flex items-center gap-2 text-sm">
+                        <input type="radio" name="decay_function" value="exponential" x-model="fn" {% if not can_write %}disabled{% endif %}>
+                        exponential (half-life days)
+                    </label>
+                </div>
+            </div>
+            <div>
+                <label for="cat-param" class="block text-xs font-medium text-slate-600 dark:text-slate-400">
+                    <span x-text="fn === 'linear' ? 'Days to zero' : 'Half-life days'"></span>
+                </label>
+                <input type="number" id="cat-param" name="decay_param" step="0.1" min="0.1" x-model.number="param" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div class="md:col-span-2">
+                <label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
+                    <input type="checkbox" name="is_active" value="1" {% if category.is_active %}checked{% endif %} {% if not can_write %}disabled{% endif %}>
+                    active
+                </label>
+                <p class="mt-1 text-xs text-slate-400">Soft-delete a referenced category by unchecking "active".</p>
+            </div>
+        </div>
+
+        <section class="mt-6">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Decay preview (0–60 days)</h2>
+            <svg viewBox="0 0 600 200" class="mt-2 w-full max-w-xl rounded border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-950" preserveAspectRatio="none">
+                <path :d="path()" stroke="currentColor" class="text-indigo-500" fill="none" stroke-width="2"/>
+                <line x1="0" y1="200" x2="600" y2="200" class="stroke-slate-300 dark:stroke-slate-700" stroke-width="1"/>
+                <line x1="0" y1="0" x2="0" y2="200" class="stroke-slate-300 dark:stroke-slate-700" stroke-width="1"/>
+            </svg>
+            <p class="mt-1 text-xs text-slate-400">x-axis = age in days (0–60); y-axis = decay multiplier (1.0 → 0).</p>
+        </section>
+
+        {% if can_write %}
+            <div class="mt-6 flex justify-end">
+                <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Save</button>
+            </div>
+        {% endif %}
+    </form>
+</div>
+
+<script>
+window.decayPreview = function (initial) {
+    return {
+        fn: initial.fn,
+        param: initial.param,
+        decay(ageDays) {
+            const p = Math.max(0.1, Number(this.param) || 0.1);
+            if (this.fn === 'linear') {
+                return Math.max(0, 1 - ageDays / p);
+            }
+            return Math.pow(0.5, ageDays / p);
+        },
+        path() {
+            const w = 600;
+            const h = 200;
+            const points = [];
+            for (let i = 0; i <= 60; i++) {
+                const x = (i / 60) * w;
+                const y = h - this.decay(i) * h;
+                points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
+            }
+            return points.join(' ');
+        },
+    };
+};
+</script>
+{% endblock %}

+ 95 - 0
ui/resources/views/pages/categories/index.twig

@@ -0,0 +1,95 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Categories — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Abuse categories</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+
+    {% if can_write %}
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">New category</h2>
+            <form method="post" action="/app/categories" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                <div>
+                    <label for="cat-slug" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Slug</label>
+                    <input type="text" id="cat-slug" name="slug" required pattern="[a-z][a-z0-9_]*"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                    <p class="mt-1 text-xs text-slate-400">lowercase + digits + underscore</p>
+                </div>
+                <div>
+                    <label for="cat-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                    <input type="text" id="cat-name" name="name" required
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div>
+                    <label for="cat-fn" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Decay function</label>
+                    <select id="cat-fn" name="decay_function" required
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="exponential">exponential (half-life days)</option>
+                        <option value="linear">linear (days to zero)</option>
+                    </select>
+                </div>
+                <div>
+                    <label for="cat-param" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Decay param</label>
+                    <input type="number" id="cat-param" name="decay_param" step="0.1" min="0.1" value="14"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-2">
+                    <label for="cat-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                    <input type="text" id="cat-desc" name="description"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-3 flex justify-end">
+                    <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Create</button>
+                </div>
+            </form>
+        </section>
+    {% endif %}
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">Slug</th>
+                    <th class="px-4 py-2 font-medium">Name</th>
+                    <th class="px-4 py-2 font-medium">Decay</th>
+                    <th class="px-4 py-2 font-medium">Status</th>
+                    {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for c in list.items|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2"><a href="/app/categories/{{ c.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ c.slug }}</a></td>
+                        <td class="px-4 py-2">{{ c.name }}</td>
+                        <td class="px-4 py-2 text-xs text-slate-500 dark:text-slate-400">{{ c.decay_function }} ({{ c.decay_param }})</td>
+                        <td class="px-4 py-2">
+                            {% if c.is_active %}
+                                <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
+                            {% else %}
+                                <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs uppercase text-slate-600 dark:bg-slate-800 dark:text-slate-400">inactive</span>
+                            {% endif %}
+                        </td>
+                        {% if can_write %}
+                            <td class="px-4 py-2 text-right">
+                                <a href="/app/categories/{{ c.id }}" class="mr-2 text-xs text-indigo-600 hover:underline dark:text-indigo-400">Edit</a>
+                                {% include 'partials/confirm_form.twig' with {
+                                    action: '/app/categories/' ~ c.id ~ '/delete',
+                                    label: 'Delete',
+                                    description: 'Refused if any policy or report references it. PATCH is_active=false on the edit page to soft-delete instead.',
+                                } only %}
+                            </td>
+                        {% endif %}
+                    </tr>
+                {% else %}
+                    <tr><td colspan="5" class="px-4 py-6 text-center text-slate-400">No categories.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+{% endblock %}

+ 46 - 0
ui/resources/views/pages/consumers/edit.twig

@@ -0,0 +1,46 @@
+{% extends 'layout.twig' %}
+
+{% block title %}{{ consumer.name }} — Consumer — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-3xl">
+    <a href="/app/consumers" class="text-sm text-slate-500 hover:underline dark:text-slate-400">← Back to consumers</a>
+    <h1 class="mt-3 text-2xl font-semibold tracking-tight font-mono">{{ consumer.name }}</h1>
+
+    <form method="post" action="/app/consumers/{{ consumer.id }}" class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <div class="grid grid-cols-1 gap-3 md:grid-cols-2 text-sm">
+            <div>
+                <label for="c-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                <input type="text" id="c-name" name="name" value="{{ consumer.name }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div>
+                <label for="c-policy" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Policy</label>
+                <select id="c-policy" name="policy_id" {% if not can_write %}disabled{% endif %}
+                        class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                    {% for p in policies %}
+                        <option value="{{ p.id }}" {% if consumer.policy_id == p.id %}selected{% endif %}>{{ p.name }}</option>
+                    {% endfor %}
+                </select>
+            </div>
+            <div class="md:col-span-2">
+                <label for="c-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                <input type="text" id="c-desc" name="description" value="{{ consumer.description|default('') }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div class="md:col-span-2">
+                <label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
+                    <input type="checkbox" name="is_active" value="1" {% if consumer.is_active %}checked{% endif %} {% if not can_write %}disabled{% endif %}>
+                    active
+                </label>
+            </div>
+        </div>
+        {% if can_write %}
+            <div class="mt-4 flex justify-end">
+                <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Save</button>
+            </div>
+        {% endif %}
+    </form>
+</div>
+{% endblock %}

+ 93 - 0
ui/resources/views/pages/consumers/index.twig

@@ -0,0 +1,93 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Consumers — IRDB{% endblock %}
+
+{% block content %}
+{% set policy_name_by_id = {} %}
+{% for p in policies %}
+    {% set policy_name_by_id = policy_name_by_id|merge({(p.id): p.name}) %}
+{% endfor %}
+
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Consumers</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+
+    {% if can_write %}
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">New consumer</h2>
+            <form method="post" action="/app/consumers" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                <div>
+                    <label for="c-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                    <input type="text" id="c-name" name="name" required
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div>
+                    <label for="c-policy" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Policy</label>
+                    <select id="c-policy" name="policy_id" required
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="">— pick one —</option>
+                        {% for p in policies %}
+                            <option value="{{ p.id }}">{{ p.name }}</option>
+                        {% endfor %}
+                    </select>
+                </div>
+                <div>
+                    <label for="c-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                    <input type="text" id="c-desc" name="description"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-3 flex justify-end">
+                    <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Create</button>
+                </div>
+            </form>
+        </section>
+    {% endif %}
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">Name</th>
+                    <th class="px-4 py-2 font-medium">Policy</th>
+                    <th class="px-4 py-2 font-medium">Description</th>
+                    <th class="px-4 py-2 font-medium">Status</th>
+                    {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for c in list.data|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2"><a href="/app/consumers/{{ c.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ c.name }}</a></td>
+                        <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ policy_name_by_id[c.policy_id]|default('?') }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ c.description|default('—') }}</td>
+                        <td class="px-4 py-2">
+                            {% if c.is_active %}
+                                <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
+                            {% else %}
+                                <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs uppercase text-slate-600 dark:bg-slate-800 dark:text-slate-400">inactive</span>
+                            {% endif %}
+                        </td>
+                        {% if can_write %}
+                            <td class="px-4 py-2 text-right">
+                                <a href="/app/consumers/{{ c.id }}" class="mr-2 text-xs text-indigo-600 hover:underline dark:text-indigo-400">Edit</a>
+                                {% if c.is_active %}
+                                    {% include 'partials/confirm_form.twig' with {
+                                        action: '/app/consumers/' ~ c.id ~ '/delete',
+                                        label: 'Deactivate',
+                                        description: 'Soft-delete this consumer.',
+                                    } only %}
+                                {% endif %}
+                            </td>
+                        {% endif %}
+                    </tr>
+                {% else %}
+                    <tr><td colspan="5" class="px-4 py-6 text-center text-slate-400">No consumers.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+{% endblock %}

+ 58 - 0
ui/resources/views/pages/ips/detail.twig

@@ -32,6 +32,64 @@
     </div>
     <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ detail.isIpv4 ? 'IPv4' : 'IPv6' }}</p>
 
+    {% if can_write|default(false) %}
+        <div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
+            {% if detail.allowlist %}
+                <form method="post" action="/app/allowlist/{{ detail.allowlist.id }}/delete" class="inline">
+                    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                    <input type="hidden" name="next" value="/app/ips/{{ detail.ip|url_encode }}">
+                    <button type="submit" class="rounded-md border border-emerald-300 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-300 dark:hover:bg-slate-800">Remove from allowlist</button>
+                </form>
+            {% else %}
+                <div x-data="{ open: false }" class="inline">
+                    <button type="button" x-on:click="open = true" class="rounded-md border border-emerald-300 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-300 dark:hover:bg-slate-800">Add to allowlist…</button>
+                    <div x-show="open" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
+                        <form method="post" action="/app/allowlist" x-on:click.outside="open = false" class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
+                            <h2 class="text-base font-semibold">Add {{ detail.ip }} to allowlist</h2>
+                            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                            <input type="hidden" name="kind" value="ip">
+                            <input type="hidden" name="ip" value="{{ detail.ip }}">
+                            <label class="mt-3 block text-xs font-medium text-slate-600 dark:text-slate-400">Reason (optional)</label>
+                            <input type="text" name="reason" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+                            <div class="mt-4 flex justify-end gap-2">
+                                <button type="button" x-on:click="open = false" class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                                <button type="submit" class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500">Add</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            {% endif %}
+
+            {% if detail.manualBlock %}
+                <form method="post" action="/app/manual-blocks/{{ detail.manualBlock.id }}/delete" class="inline">
+                    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                    <input type="hidden" name="next" value="/app/ips/{{ detail.ip|url_encode }}">
+                    <button type="submit" class="rounded-md border border-amber-300 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50 dark:border-amber-700 dark:text-amber-300 dark:hover:bg-slate-800">Remove manual block</button>
+                </form>
+            {% else %}
+                <div x-data="{ open: false }" class="inline">
+                    <button type="button" x-on:click="open = true" class="rounded-md border border-amber-300 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50 dark:border-amber-700 dark:text-amber-300 dark:hover:bg-slate-800">Manually block…</button>
+                    <div x-show="open" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
+                        <form method="post" action="/app/manual-blocks" x-on:click.outside="open = false" class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
+                            <h2 class="text-base font-semibold">Manually block {{ detail.ip }}</h2>
+                            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                            <input type="hidden" name="kind" value="ip">
+                            <input type="hidden" name="ip" value="{{ detail.ip }}">
+                            <label class="mt-3 block text-xs font-medium text-slate-600 dark:text-slate-400">Reason (optional)</label>
+                            <input type="text" name="reason" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+                            <label class="mt-3 block text-xs font-medium text-slate-600 dark:text-slate-400">Expires at (optional)</label>
+                            <input type="datetime-local" name="expires_at" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+                            <div class="mt-4 flex justify-end gap-2">
+                                <button type="button" x-on:click="open = false" class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                                <button type="submit" class="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-500">Block</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            {% endif %}
+        </div>
+    {% endif %}
+
     <section class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
         <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Enrichment</h2>

+ 106 - 0
ui/resources/views/pages/manual-blocks/index.twig

@@ -0,0 +1,106 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Manual blocks — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">
+            {% if active_section == 'subnets' %}Subnets{% else %}Manual blocks{% endif %}
+        </h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+
+    {% set kind_links = [
+        { label: 'All',     value: '' },
+        { label: 'IPs',     value: 'ip' },
+        { label: 'Subnets', value: 'subnet' },
+    ] %}
+    <div class="mt-4 flex gap-2 text-sm">
+        {% for k in kind_links %}
+            {% set is_active = (kind|default('') == k.value) or (kind == null and k.value == '') %}
+            <a href="/app/manual-blocks{% if k.value %}?kind={{ k.value }}{% endif %}"
+               class="rounded-full px-3 py-1 {% if is_active %}bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-100{% else %}border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800{% endif %}">
+                {{ k.label }}
+            </a>
+        {% endfor %}
+    </div>
+
+    {% if can_write %}
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Add manual block</h2>
+            <form method="post" action="/app/manual-blocks" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-4 text-sm" x-data="{ kind: '{{ kind|default('subnet') }}' }">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                <div>
+                    <label for="mb-kind" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Kind</label>
+                    <select id="mb-kind" name="kind" x-model="kind"
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="ip">Single IP</option>
+                        <option value="subnet">Subnet (CIDR)</option>
+                    </select>
+                </div>
+                <div x-show="kind == 'ip'">
+                    <label for="mb-ip" class="block text-xs font-medium text-slate-600 dark:text-slate-400">IP</label>
+                    <input type="text" id="mb-ip" name="ip" placeholder="203.0.113.5"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div x-show="kind == 'subnet'">
+                    <label for="mb-cidr" class="block text-xs font-medium text-slate-600 dark:text-slate-400">CIDR</label>
+                    <input type="text" id="mb-cidr" name="cidr" placeholder="192.0.2.0/24"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div>
+                    <label for="mb-reason" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Reason</label>
+                    <input type="text" id="mb-reason" name="reason"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div>
+                    <label for="mb-expires" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Expires (optional)</label>
+                    <input type="datetime-local" id="mb-expires" name="expires_at"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-4 flex justify-end">
+                    <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Add block</button>
+                </div>
+            </form>
+        </section>
+    {% endif %}
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">Kind</th>
+                    <th class="px-4 py-2 font-medium">Target</th>
+                    <th class="px-4 py-2 font-medium">Reason</th>
+                    <th class="px-4 py-2 font-medium">Expires</th>
+                    <th class="px-4 py-2 font-medium">Created</th>
+                    {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for item in list.items|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
+                        <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.expires_at|default('—') }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.created_at }}</td>
+                        {% if can_write %}
+                            <td class="px-4 py-2 text-right">
+                                {% include 'partials/confirm_form.twig' with {
+                                    action: '/app/manual-blocks/' ~ item.id ~ '/delete',
+                                    label: 'Remove',
+                                    description: 'This removes the manual block immediately.',
+                                } only %}
+                            </td>
+                        {% endif %}
+                    </tr>
+                {% else %}
+                    <tr><td colspan="6" class="px-4 py-6 text-center text-slate-400">No manual blocks.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+{% endblock %}

+ 138 - 0
ui/resources/views/pages/policies/edit.twig

@@ -0,0 +1,138 @@
+{% extends 'layout.twig' %}
+
+{% block title %}{{ policy.name }} — Policy — IRDB{% endblock %}
+
+{% block content %}
+{% set thresholds_by_id = {} %}
+{% for t in policy.thresholds|default([]) %}
+    {% set thresholds_by_id = thresholds_by_id|merge({(t.category_id): t.threshold}) %}
+{% endfor %}
+
+<div class="mx-auto max-w-5xl">
+    <a href="/app/policies" class="text-sm text-slate-500 hover:underline dark:text-slate-400">← Back to policies</a>
+
+    <div class="mt-3 flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">
+            <span class="font-mono">{{ policy.name }}</span>
+        </h1>
+        {% if can_write %}
+            {% include 'partials/confirm_form.twig' with {
+                action: '/app/policies/' ~ policy.id ~ '/delete',
+                label: 'Delete policy',
+                description: 'Refused if any consumer references this policy.',
+            } only %}
+        {% endif %}
+    </div>
+
+    <form method="post" action="/app/policies/{{ policy.id }}" class="mt-6"
+          {% if can_write %}hx-post="/api/v1/admin/policies/{{ policy.id }}/preview"{% endif %}>
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+
+        <section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Metadata</h2>
+            <div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
+                <div>
+                    <label for="p-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                    <input type="text" id="p-name" name="name" value="{{ policy.name }}" {% if not can_write %}readonly{% endif %}
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-2">
+                    <label for="p-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                    <input type="text" id="p-desc" name="description" value="{{ policy.description|default('') }}" {% if not can_write %}readonly{% endif %}
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-3">
+                    <label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
+                        <input type="checkbox" name="include_manual_blocks" value="1"
+                               {% if policy.include_manual_blocks %}checked{% endif %}
+                               {% if not can_write %}disabled{% endif %}>
+                        include manual blocks
+                    </label>
+                </div>
+            </div>
+        </section>
+
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <div class="flex items-center justify-between">
+                <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Threshold matrix</h2>
+                <span class="text-xs text-slate-400">Empty value ⇒ category not in policy</span>
+            </div>
+            <table class="mt-3 w-full text-sm">
+                <thead class="text-left text-xs uppercase tracking-wider text-slate-400">
+                    <tr>
+                        <th class="pb-2 font-medium">Category</th>
+                        <th class="pb-2 font-medium">Decay</th>
+                        <th class="pb-2 text-right font-medium">Threshold</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                    {% for c in categories %}
+                        <tr>
+                            <td class="py-2"><span class="font-mono">{{ c.slug }}</span> <span class="text-slate-400">— {{ c.name }}</span></td>
+                            <td class="py-2 text-xs text-slate-500 dark:text-slate-400">{{ c.decay_function }} ({{ c.decay_param }})</td>
+                            <td class="py-2 text-right">
+                                <input type="number" step="0.01" min="0"
+                                       name="thresholds[{{ c.slug }}]"
+                                       value="{{ thresholds_by_id[c.id]|default('') }}"
+                                       {% if not can_write %}readonly{% endif %}
+                                       class="w-32 rounded-md border border-slate-300 bg-white px-2 py-1 text-right font-mono dark:border-slate-700 dark:bg-slate-950">
+                            </td>
+                        </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </section>
+
+        {% if can_write %}
+            <div class="mt-6 flex justify-end">
+                <button type="submit" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">Save policy</button>
+            </div>
+        {% endif %}
+    </form>
+
+    <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
+             x-data="policyPreview({{ policy.id }})" x-init="load()">
+        <div class="flex items-center justify-between">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Preview</h2>
+            <button type="button" x-on:click="load()" class="text-xs text-indigo-600 hover:underline dark:text-indigo-400">Refresh</button>
+        </div>
+        <p class="mt-2 text-sm">
+            <span x-show="loading">Loading…</span>
+            <template x-if="!loading">
+                <span><span class="font-mono" x-text="count"></span> entries</span>
+            </template>
+        </p>
+        <ul class="mt-3 max-h-60 space-y-1 overflow-y-auto font-mono text-xs text-slate-700 dark:text-slate-300">
+            <template x-for="entry in sample" :key="entry">
+                <li x-text="entry"></li>
+            </template>
+        </ul>
+        <p class="mt-2 text-xs text-slate-400">Sample = first 50 entries from the rendered blocklist.</p>
+    </section>
+</div>
+
+<script>
+window.policyPreview = function (id) {
+    return {
+        loading: true,
+        count: 0,
+        sample: [],
+        async load() {
+            this.loading = true;
+            try {
+                const res = await fetch('/app/policies/' + id + '/preview-proxy', { credentials: 'same-origin' });
+                if (!res.ok) throw new Error('preview ' + res.status);
+                const data = await res.json();
+                this.count = data.count || 0;
+                this.sample = data.sample || [];
+            } catch (e) {
+                this.count = 0;
+                this.sample = ['(preview unavailable)'];
+            } finally {
+                this.loading = false;
+            }
+        },
+    };
+};
+</script>
+{% endblock %}

+ 74 - 0
ui/resources/views/pages/policies/index.twig

@@ -0,0 +1,74 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Policies — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Policies</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+
+    {% if can_write %}
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">New policy</h2>
+            <form method="post" action="/app/policies" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                <div>
+                    <label for="p-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                    <input type="text" id="p-name" name="name" required
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-2">
+                    <label for="p-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                    <input type="text" id="p-desc" name="description"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-3 flex items-center justify-between">
+                    <label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
+                        <input type="checkbox" name="include_manual_blocks" value="1" checked> include manual blocks
+                    </label>
+                    <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Create</button>
+                </div>
+                <p class="md:col-span-3 text-xs text-slate-400">Thresholds are configured on the policy edit page after creation.</p>
+            </form>
+        </section>
+    {% endif %}
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">Name</th>
+                    <th class="px-4 py-2 font-medium">Description</th>
+                    <th class="px-4 py-2 text-right font-medium">Categories</th>
+                    <th class="px-4 py-2 font-medium">Manual blocks</th>
+                    {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for p in list.items|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2"><a href="/app/policies/{{ p.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ p.name }}</a></td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ p.description|default('—') }}</td>
+                        <td class="px-4 py-2 text-right font-mono">{{ p.thresholds|length }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ p.include_manual_blocks ? 'yes' : 'no' }}</td>
+                        {% if can_write %}
+                            <td class="px-4 py-2 text-right">
+                                <a href="/app/policies/{{ p.id }}" class="mr-2 text-xs text-indigo-600 hover:underline dark:text-indigo-400">Edit</a>
+                                {% include 'partials/confirm_form.twig' with {
+                                    action: '/app/policies/' ~ p.id ~ '/delete',
+                                    label: 'Delete',
+                                    description: 'Delete this policy. Refused if any consumer references it.',
+                                } only %}
+                            </td>
+                        {% endif %}
+                    </tr>
+                {% else %}
+                    <tr><td colspan="5" class="px-4 py-6 text-center text-slate-400">No policies.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+{% endblock %}

+ 43 - 0
ui/resources/views/pages/reporters/edit.twig

@@ -0,0 +1,43 @@
+{% extends 'layout.twig' %}
+
+{% block title %}{{ reporter.name }} — Reporter — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-3xl">
+    <a href="/app/reporters" class="text-sm text-slate-500 hover:underline dark:text-slate-400">← Back to reporters</a>
+    <h1 class="mt-3 text-2xl font-semibold tracking-tight font-mono">{{ reporter.name }}</h1>
+
+    <form method="post" action="/app/reporters/{{ reporter.id }}" class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <div class="grid grid-cols-1 gap-3 md:grid-cols-2 text-sm">
+            <div>
+                <label for="r-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                <input type="text" id="r-name" name="name" value="{{ reporter.name }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div>
+                <label for="r-tw" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Trust weight</label>
+                <input type="number" id="r-tw" name="trust_weight" step="0.01" min="0" max="2" value="{{ reporter.trust_weight }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                <p class="mt-1 text-xs text-slate-400">0–2.0; 1.0 default; affects how heavily this reporter influences scores.</p>
+            </div>
+            <div class="md:col-span-2">
+                <label for="r-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                <input type="text" id="r-desc" name="description" value="{{ reporter.description|default('') }}" {% if not can_write %}readonly{% endif %}
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div class="md:col-span-2">
+                <label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
+                    <input type="checkbox" name="is_active" value="1" {% if reporter.is_active %}checked{% endif %} {% if not can_write %}disabled{% endif %}>
+                    active
+                </label>
+            </div>
+        </div>
+        {% if can_write %}
+            <div class="mt-4 flex justify-end">
+                <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Save</button>
+            </div>
+        {% endif %}
+    </form>
+</div>
+{% endblock %}

+ 84 - 0
ui/resources/views/pages/reporters/index.twig

@@ -0,0 +1,84 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Reporters — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Reporters</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+
+    {% if can_write %}
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">New reporter</h2>
+            <form method="post" action="/app/reporters" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                <div>
+                    <label for="r-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
+                    <input type="text" id="r-name" name="name" required
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div>
+                    <label for="r-tw" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Trust weight</label>
+                    <input type="number" id="r-tw" name="trust_weight" step="0.01" min="0" max="2" value="1.0"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                    <p class="mt-1 text-xs text-slate-400">0–2.0; 1.0 default; affects how heavily this reporter influences scores.</p>
+                </div>
+                <div>
+                    <label for="r-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
+                    <input type="text" id="r-desc" name="description"
+                           class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                </div>
+                <div class="md:col-span-3 flex justify-end">
+                    <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Create</button>
+                </div>
+            </form>
+        </section>
+    {% endif %}
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">Name</th>
+                    <th class="px-4 py-2 text-right font-medium">Trust</th>
+                    <th class="px-4 py-2 font-medium">Description</th>
+                    <th class="px-4 py-2 font-medium">Status</th>
+                    {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for r in list.data|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2"><a href="/app/reporters/{{ r.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ r.name }}</a></td>
+                        <td class="px-4 py-2 text-right font-mono">{{ r.trust_weight|number_format(2) }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ r.description|default('—') }}</td>
+                        <td class="px-4 py-2">
+                            {% if r.is_active %}
+                                <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
+                            {% else %}
+                                <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs uppercase text-slate-600 dark:bg-slate-800 dark:text-slate-400">inactive</span>
+                            {% endif %}
+                        </td>
+                        {% if can_write %}
+                            <td class="px-4 py-2 text-right">
+                                <a href="/app/reporters/{{ r.id }}" class="mr-2 text-xs text-indigo-600 hover:underline dark:text-indigo-400">Edit</a>
+                                {% if r.is_active %}
+                                    {% include 'partials/confirm_form.twig' with {
+                                        action: '/app/reporters/' ~ r.id ~ '/delete',
+                                        label: 'Deactivate',
+                                        description: 'Soft-delete this reporter. Existing reports stay; new ingest fails.',
+                                    } only %}
+                                {% endif %}
+                            </td>
+                        {% endif %}
+                    </tr>
+                {% else %}
+                    <tr><td colspan="5" class="px-4 py-6 text-center text-slate-400">No reporters yet.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+{% endblock %}

+ 140 - 0
ui/resources/views/pages/tokens/index.twig

@@ -0,0 +1,140 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Tokens — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">API tokens</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+
+    {% if can_write %}
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
+                 x-data="{ kind: 'admin' }">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Issue token</h2>
+            <form method="post" action="/app/tokens" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                <div>
+                    <label for="t-kind" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Kind</label>
+                    <select id="t-kind" name="kind" x-model="kind"
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="admin">admin</option>
+                        <option value="reporter">reporter</option>
+                        <option value="consumer">consumer</option>
+                    </select>
+                </div>
+                <div x-show="kind == 'admin'">
+                    <label for="t-role" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Role</label>
+                    <select id="t-role" name="role"
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="viewer">viewer</option>
+                        <option value="operator">operator</option>
+                        <option value="admin">admin</option>
+                    </select>
+                </div>
+                <div x-show="kind == 'reporter'">
+                    <label for="t-rep" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Reporter</label>
+                    <select id="t-rep" name="reporter_id"
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="">— pick one —</option>
+                        {% for r in reporters %}
+                            <option value="{{ r.id }}">{{ r.name }}</option>
+                        {% endfor %}
+                    </select>
+                </div>
+                <div x-show="kind == 'consumer'">
+                    <label for="t-con" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Consumer</label>
+                    <select id="t-con" name="consumer_id"
+                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
+                        <option value="">— pick one —</option>
+                        {% for c in consumers %}
+                            <option value="{{ c.id }}">{{ c.name }}</option>
+                        {% endfor %}
+                    </select>
+                </div>
+                <div class="md:col-span-3 flex justify-end">
+                    <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Issue</button>
+                </div>
+            </form>
+        </section>
+    {% endif %}
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">Kind</th>
+                    <th class="px-4 py-2 font-medium">Prefix</th>
+                    <th class="px-4 py-2 font-medium">Role / target</th>
+                    <th class="px-4 py-2 font-medium">Last used</th>
+                    <th class="px-4 py-2 font-medium">Status</th>
+                    {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for t in list.data|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2 font-mono text-xs uppercase">{{ t.kind }}</td>
+                        <td class="px-4 py-2 font-mono">{{ t.token_prefix }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">
+                            {%- if t.kind == 'admin' -%}role: <span class="font-mono">{{ t.role|default('—') }}</span>
+                            {%- elseif t.kind == 'reporter' -%}reporter #{{ t.reporter_id }}
+                            {%- elseif t.kind == 'consumer' -%}consumer #{{ t.consumer_id }}
+                            {%- else -%}—{%- endif -%}
+                        </td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ t.last_used_at|default('never') }}</td>
+                        <td class="px-4 py-2">
+                            {% if t.revoked_at %}
+                                <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs uppercase text-slate-500 dark:bg-slate-800 dark:text-slate-400">revoked</span>
+                            {% else %}
+                                <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
+                            {% endif %}
+                        </td>
+                        {% if can_write %}
+                            <td class="px-4 py-2 text-right">
+                                {% if not t.revoked_at %}
+                                    {% include 'partials/confirm_form.twig' with {
+                                        action: '/app/tokens/' ~ t.id ~ '/delete',
+                                        label: 'Revoke',
+                                        description: 'Revoke this token immediately. Clients using it will start getting 401.',
+                                    } only %}
+                                {% endif %}
+                            </td>
+                        {% endif %}
+                    </tr>
+                {% else %}
+                    <tr><td colspan="6" class="px-4 py-6 text-center text-slate-400">No tokens.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+
+{% if just_created %}
+<div x-data="{ open: true }" x-init="$nextTick(() => open = true)"
+     class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4">
+    <div x-show="open" class="w-full max-w-lg rounded-2xl border border-amber-300 bg-white p-6 shadow-2xl dark:border-amber-700 dark:bg-slate-900">
+        <h2 class="text-lg font-semibold text-amber-700 dark:text-amber-300">Copy this token now</h2>
+        <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
+            This is the only time you'll see the raw token. Refreshing this page or closing the dialog discards it permanently.
+            If you lose it, revoke it and issue a new one.
+        </p>
+        <div class="mt-4">
+            <label class="block text-xs font-medium text-slate-500 dark:text-slate-400">Kind: <span class="font-mono">{{ just_created.kind }}</span> · prefix: <span class="font-mono">{{ just_created.token_prefix }}</span></label>
+            <div class="mt-1 flex items-center gap-2">
+                <input id="raw-token" type="text" readonly value="{{ just_created.raw_token }}"
+                       class="w-full rounded-md border border-slate-300 bg-slate-50 px-3 py-2 font-mono text-xs dark:border-slate-700 dark:bg-slate-950">
+                <button type="button"
+                        x-on:click="navigator.clipboard.writeText(document.getElementById('raw-token').value)"
+                        class="rounded-md border border-slate-300 px-3 py-2 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Copy</button>
+            </div>
+        </div>
+        <div class="mt-6 flex justify-end">
+            <button type="button" x-on:click="open = false"
+                    class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500">I have stored it safely</button>
+        </div>
+    </div>
+</div>
+{% endif %}
+{% endblock %}

+ 37 - 0
ui/resources/views/partials/confirm_form.twig

@@ -0,0 +1,37 @@
+{# Reusable confirm-then-submit form. Renders a button that opens a small Alpine
+   modal asking the user to confirm before posting to `action`. The form's
+   hidden inputs include the CSRF token and any extra inputs in `extra_fields`.
+
+   Args:
+     - action       (string)  : POST URL.
+     - label        (string)  : button text and modal title prefix.
+     - description  (string)  : line of explanatory text in the modal.
+     - extra_fields (array)   : optional name->value hidden inputs (for "next").
+     - btn_class    (string)  : optional override for the trigger-button classes. #}
+
+{% set _btn_class = btn_class|default('rounded-md border border-red-300 bg-white px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-slate-900 dark:text-red-300 dark:hover:bg-slate-800') %}
+
+<div x-data="{ open: false }" class="inline-block">
+    <button type="button"
+            x-on:click="open = true"
+            class="{{ _btn_class }}">{{ label }}</button>
+
+    <div x-show="open" x-cloak
+         class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
+        <div x-on:click.outside="open = false"
+             class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-base font-semibold">{{ label }}?</h2>
+            <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">{{ description }}</p>
+            <form method="post" action="{{ action }}" class="mt-4 flex justify-end gap-2">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                {% for k, v in extra_fields|default({}) %}
+                    <input type="hidden" name="{{ k }}" value="{{ v }}">
+                {% endfor %}
+                <button type="button" x-on:click="open = false"
+                        class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                <button type="submit"
+                        class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-500">Confirm</button>
+            </form>
+        </div>
+    </div>
+</div>

+ 13 - 12
ui/resources/views/partials/sidebar.twig

@@ -1,18 +1,19 @@
 <aside class="hidden w-56 border-r border-slate-200 bg-white px-3 py-6 text-sm dark:border-slate-800 dark:bg-slate-950 md:block">
     <nav class="flex flex-col gap-1">
         {% set links = [
-            { href: '/app/dashboard', label: 'Dashboard',  section: 'dashboard' },
-            { href: '/app/ips',       label: 'IPs',        section: 'ips' },
-            { href: '#', label: 'Subnets',    upcoming: 'M10' },
-            { href: '#', label: 'Allowlist',  upcoming: 'M10' },
-            { href: '#', label: 'Policies',   upcoming: 'M10' },
-            { href: '#', label: 'Reporters',  upcoming: 'M10' },
-            { href: '#', label: 'Consumers',  upcoming: 'M10' },
-            { href: '#', label: 'Tokens',     upcoming: 'M10' },
-            { href: '#', label: 'Categories', upcoming: 'M10' },
-            { href: '#', label: 'Audit',      upcoming: 'M12' },
-            { href: '#', label: 'Settings',   upcoming: 'M12' },
-            { href: '/app/me',        label: 'My identity', section: 'me' },
+            { href: '/app/dashboard',     label: 'Dashboard',     section: 'dashboard' },
+            { href: '/app/ips',           label: 'IPs',           section: 'ips' },
+            { href: '/app/subnets',       label: 'Subnets',       section: 'subnets' },
+            { href: '/app/manual-blocks', label: 'Manual blocks', section: 'manual-blocks' },
+            { href: '/app/allowlist',     label: 'Allowlist',     section: 'allowlist' },
+            { href: '/app/policies',      label: 'Policies',      section: 'policies' },
+            { href: '/app/reporters',     label: 'Reporters',     section: 'reporters' },
+            { href: '/app/consumers',     label: 'Consumers',     section: 'consumers' },
+            { href: '/app/tokens',        label: 'Tokens',        section: 'tokens' },
+            { href: '/app/categories',    label: 'Categories',    section: 'categories' },
+            { href: '#', label: 'Audit',     upcoming: 'M12' },
+            { href: '#', label: 'Settings',  upcoming: 'M12' },
+            { href: '/app/me',            label: 'My identity',   section: 'me' },
         ] %}
         {% for link in links %}
             {% if link.upcoming is defined %}

+ 268 - 4
ui/src/ApiClient/AdminClient.php

@@ -15,9 +15,15 @@ use App\ApiClient\DTOs\UserDto;
  * api uses that to resolve the impersonated user's role and enforce
  * RBAC.
  *
- * Method surface grows with each milestone. M10–M12 add CRUD over
- * policies, tokens, audit, settings; only the read-only endpoints are
- * here for M09.
+ * For most CRUD endpoints (manual_blocks, allowlist, policies,
+ * reporters, consumers, tokens, categories) we return raw associative
+ * arrays mirroring the api's JSON shape. Templates bind onto these
+ * directly. The richer DTO pattern is reserved for endpoints whose
+ * response shape benefits from a typed accessor (`UserDto`, the
+ * IP-detail / dashboard payloads).
+ *
+ * Throws the typed `ApiException` subclasses on non-2xx; controllers
+ * catch them to render validation messages or "API unreachable" states.
  */
 final class AdminClient
 {
@@ -25,6 +31,8 @@ final class AdminClient
     {
     }
 
+    // ---- identity ----
+
     public function getMe(int $actingUserId): UserDto
     {
         $payload = $this->api->request('GET', '/api/v1/admin/me', [], $actingUserId);
@@ -32,8 +40,10 @@ final class AdminClient
         return UserDto::fromArray($payload);
     }
 
+    // ---- IPs / dashboard (M09) ----
+
     /**
-     * @param array<string, mixed> $filters {q, category, min_score, max_score, country, asn, status}
+     * @param array<string, mixed> $filters
      */
     public function searchIps(int $actingUserId, array $filters, int $page = 1, int $pageSize = 25): IpListDto
     {
@@ -61,4 +71,258 @@ final class AdminClient
 
         return DashboardStatsDto::fromArray($payload);
     }
+
+    // ---- manual blocks (M10) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listManualBlocks(int $actingUserId, ?string $kind = null): array
+    {
+        $query = ['limit' => 200];
+        if ($kind !== null && $kind !== '') {
+            $query['kind'] = $kind;
+        }
+
+        return $this->api->request('GET', '/api/v1/admin/manual-blocks', ['query' => $query], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function createManualBlock(int $actingUserId, array $body): array
+    {
+        return $this->api->request('POST', '/api/v1/admin/manual-blocks', ['json' => $body], $actingUserId);
+    }
+
+    public function deleteManualBlock(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/manual-blocks/' . $id, [], $actingUserId);
+    }
+
+    // ---- allowlist (M10) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listAllowlist(int $actingUserId, ?string $kind = null): array
+    {
+        $query = ['limit' => 200];
+        if ($kind !== null && $kind !== '') {
+            $query['kind'] = $kind;
+        }
+
+        return $this->api->request('GET', '/api/v1/admin/allowlist', ['query' => $query], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function createAllowlist(int $actingUserId, array $body): array
+    {
+        return $this->api->request('POST', '/api/v1/admin/allowlist', ['json' => $body], $actingUserId);
+    }
+
+    public function deleteAllowlist(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/allowlist/' . $id, [], $actingUserId);
+    }
+
+    // ---- policies (M10) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listPolicies(int $actingUserId): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/policies', [], $actingUserId);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getPolicy(int $actingUserId, int $id): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/policies/' . $id, [], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function createPolicy(int $actingUserId, array $body): array
+    {
+        return $this->api->request('POST', '/api/v1/admin/policies', ['json' => $body], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function updatePolicy(int $actingUserId, int $id, array $body): array
+    {
+        return $this->api->request('PATCH', '/api/v1/admin/policies/' . $id, ['json' => $body], $actingUserId);
+    }
+
+    public function deletePolicy(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/policies/' . $id, [], $actingUserId);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function previewPolicy(int $actingUserId, int $id): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/policies/' . $id . '/preview', [], $actingUserId);
+    }
+
+    // ---- reporters (M10) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listReporters(int $actingUserId): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/reporters', ['query' => ['limit' => 200]], $actingUserId);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getReporter(int $actingUserId, int $id): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/reporters/' . $id, [], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function createReporter(int $actingUserId, array $body): array
+    {
+        return $this->api->request('POST', '/api/v1/admin/reporters', ['json' => $body], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function updateReporter(int $actingUserId, int $id, array $body): array
+    {
+        return $this->api->request('PATCH', '/api/v1/admin/reporters/' . $id, ['json' => $body], $actingUserId);
+    }
+
+    public function deleteReporter(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/reporters/' . $id, [], $actingUserId);
+    }
+
+    // ---- consumers (M10) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listConsumers(int $actingUserId): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/consumers', ['query' => ['limit' => 200]], $actingUserId);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getConsumer(int $actingUserId, int $id): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/consumers/' . $id, [], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function createConsumer(int $actingUserId, array $body): array
+    {
+        return $this->api->request('POST', '/api/v1/admin/consumers', ['json' => $body], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function updateConsumer(int $actingUserId, int $id, array $body): array
+    {
+        return $this->api->request('PATCH', '/api/v1/admin/consumers/' . $id, ['json' => $body], $actingUserId);
+    }
+
+    public function deleteConsumer(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/consumers/' . $id, [], $actingUserId);
+    }
+
+    // ---- tokens (M10) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listTokens(int $actingUserId): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/tokens', ['query' => ['limit' => 200]], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function createToken(int $actingUserId, array $body): array
+    {
+        return $this->api->request('POST', '/api/v1/admin/tokens', ['json' => $body], $actingUserId);
+    }
+
+    public function deleteToken(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/tokens/' . $id, [], $actingUserId);
+    }
+
+    // ---- categories (M10) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listCategories(int $actingUserId): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/categories', [], $actingUserId);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getCategory(int $actingUserId, int $id): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/categories/' . $id, [], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function createCategory(int $actingUserId, array $body): array
+    {
+        return $this->api->request('POST', '/api/v1/admin/categories', ['json' => $body], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    public function updateCategory(int $actingUserId, int $id, array $body): array
+    {
+        return $this->api->request('PATCH', '/api/v1/admin/categories/' . $id, ['json' => $body], $actingUserId);
+    }
+
+    public function deleteCategory(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/categories/' . $id, [], $actingUserId);
+    }
 }

+ 62 - 0
ui/src/App/AppFactory.php

@@ -7,12 +7,19 @@ namespace App\App;
 use App\Auth\LocalLoginController;
 use App\Auth\LogoutController;
 use App\Auth\OidcController;
+use App\Controllers\AllowlistController;
+use App\Controllers\CategoriesController;
+use App\Controllers\ConsumersController;
 use App\Controllers\DashboardController;
 use App\Controllers\HealthzController;
 use App\Controllers\HomeController;
 use App\Controllers\IpsController;
+use App\Controllers\ManualBlocksController;
 use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
+use App\Controllers\PoliciesController;
+use App\Controllers\ReportersController;
+use App\Controllers\TokensController;
 use App\Http\AuthRequiredMiddleware;
 use App\Http\CsrfMiddleware;
 use App\Http\JsonExceptionHandler;
@@ -118,6 +125,61 @@ final class AppFactory
             $group->get('/ips', [$ips, 'index']);
             // {ip:.+} so v6 colons don't break Slim's default segment regex.
             $group->get('/ips/{ip:.+}', [$ips, 'show']);
+
+            /** @var ManualBlocksController $manualBlocks */
+            $manualBlocks = $container->get(ManualBlocksController::class);
+            $group->get('/manual-blocks', [$manualBlocks, 'index']);
+            $group->post('/manual-blocks', [$manualBlocks, 'create']);
+            $group->post('/manual-blocks/{id}/delete', [$manualBlocks, 'delete']);
+            // /app/subnets is an alias filtered to kind=subnet so the
+            // sidebar's "Subnets" link lands on a focused list.
+            $group->get('/subnets', [$manualBlocks, 'subnetsIndex']);
+
+            /** @var AllowlistController $allowlist */
+            $allowlist = $container->get(AllowlistController::class);
+            $group->get('/allowlist', [$allowlist, 'index']);
+            $group->post('/allowlist', [$allowlist, 'create']);
+            $group->post('/allowlist/{id}/delete', [$allowlist, 'delete']);
+
+            /** @var PoliciesController $policies */
+            $policies = $container->get(PoliciesController::class);
+            $group->get('/policies', [$policies, 'index']);
+            $group->post('/policies', [$policies, 'create']);
+            $group->get('/policies/{id}', [$policies, 'edit']);
+            $group->post('/policies/{id}', [$policies, 'update']);
+            $group->post('/policies/{id}/delete', [$policies, 'delete']);
+            // GET-only XHR proxy used by the edit page's preview pane.
+            $group->get('/policies/{id}/preview-proxy', [$policies, 'previewProxy']);
+
+            /** @var ReportersController $reporters */
+            $reporters = $container->get(ReportersController::class);
+            $group->get('/reporters', [$reporters, 'index']);
+            $group->post('/reporters', [$reporters, 'create']);
+            $group->get('/reporters/{id}', [$reporters, 'edit']);
+            $group->post('/reporters/{id}', [$reporters, 'update']);
+            $group->post('/reporters/{id}/delete', [$reporters, 'delete']);
+
+            /** @var ConsumersController $consumers */
+            $consumers = $container->get(ConsumersController::class);
+            $group->get('/consumers', [$consumers, 'index']);
+            $group->post('/consumers', [$consumers, 'create']);
+            $group->get('/consumers/{id}', [$consumers, 'edit']);
+            $group->post('/consumers/{id}', [$consumers, 'update']);
+            $group->post('/consumers/{id}/delete', [$consumers, 'delete']);
+
+            /** @var TokensController $tokens */
+            $tokens = $container->get(TokensController::class);
+            $group->get('/tokens', [$tokens, 'index']);
+            $group->post('/tokens', [$tokens, 'create']);
+            $group->post('/tokens/{id}/delete', [$tokens, 'delete']);
+
+            /** @var CategoriesController $categories */
+            $categories = $container->get(CategoriesController::class);
+            $group->get('/categories', [$categories, 'index']);
+            $group->post('/categories', [$categories, 'create']);
+            $group->get('/categories/{id}', [$categories, 'edit']);
+            $group->post('/categories/{id}', [$categories, 'update']);
+            $group->post('/categories/{id}/delete', [$categories, 'delete']);
         })->add($authRequired);
 
         $app->map(

+ 14 - 0
ui/src/App/Container.php

@@ -14,12 +14,19 @@ use App\Auth\LogoutController;
 use App\Auth\OidcAuthenticator;
 use App\Auth\OidcController;
 use App\Auth\SessionManager;
+use App\Controllers\AllowlistController;
+use App\Controllers\CategoriesController;
+use App\Controllers\ConsumersController;
 use App\Controllers\DashboardController;
 use App\Controllers\HealthzController;
 use App\Controllers\HomeController;
 use App\Controllers\IpsController;
+use App\Controllers\ManualBlocksController;
 use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
+use App\Controllers\PoliciesController;
+use App\Controllers\ReportersController;
+use App\Controllers\TokensController;
 use App\Http\AuthRequiredMiddleware;
 use App\Http\CsrfMiddleware;
 use App\Http\JsonExceptionHandler;
@@ -194,6 +201,13 @@ final class Container
             LogoutController::class => autowire(),
             DashboardController::class => autowire(),
             IpsController::class => autowire(),
+            ManualBlocksController::class => autowire(),
+            AllowlistController::class => autowire(),
+            PoliciesController::class => autowire(),
+            ReportersController::class => autowire(),
+            ConsumersController::class => autowire(),
+            TokensController::class => autowire(),
+            CategoriesController::class => autowire(),
 
             LocalLoginController::class => factory(static function (ContainerInterface $c): LocalLoginController {
                 /** @var Twig $twig */

+ 130 - 0
ui/src/Controllers/AllowlistController.php

@@ -0,0 +1,130 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/allowlist` — same shape as ManualBlocksController without the
+ * `expires_at` field (allowlist entries don't auto-expire).
+ *
+ * RBAC: list ⇒ Viewer; create/delete ⇒ Operator.
+ */
+final class AllowlistController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $kind = $request->getQueryParams()['kind'] ?? null;
+        if (!is_string($kind) || !in_array($kind, ['ip', 'subnet'], true)) {
+            $kind = null;
+        }
+
+        try {
+            $list = $this->admin->listAllowlist($user->userId, $kind);
+        } catch (ApiException $e) {
+            $list = ['items' => [], 'total' => 0];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/allowlist/index.twig', [
+            'active_section' => 'allowlist',
+            'list' => $list,
+            'kind' => $kind,
+            'can_write' => $this->userIs($user, 'operator', 'admin'),
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $body = $this->formBody($request);
+        $kind = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
+        $payload = ['kind' => $kind];
+        if ($kind === 'ip' && isset($body['ip']) && is_string($body['ip'])) {
+            $payload['ip'] = trim($body['ip']);
+        }
+        if ($kind === 'subnet' && isset($body['cidr']) && is_string($body['cidr'])) {
+            $payload['cidr'] = trim($body['cidr']);
+        }
+        if (isset($body['reason']) && is_string($body['reason']) && trim($body['reason']) !== '') {
+            $payload['reason'] = trim($body['reason']);
+        }
+
+        try {
+            $this->admin->createAllowlist($user->userId, $payload);
+            $this->sessionManager->flash('success', 'Allowlist entry added.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/allowlist');
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/allowlist');
+        }
+
+        try {
+            $this->admin->deleteAllowlist($user->userId, $id);
+            $this->sessionManager->flash('success', 'Allowlist entry removed.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        $next = $this->formBody($request)['next'] ?? '/app/allowlist';
+
+        return $response->withStatus(303)->withHeader('Location', is_string($next) && $next !== '' ? $next : '/app/allowlist');
+    }
+}

+ 213 - 0
ui/src/Controllers/CategoriesController.php

@@ -0,0 +1,213 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\ApiClient\ApiNotFoundException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/categories` — list, edit, create, delete (with in-use guard).
+ *
+ * Edit page renders a small Alpine component that draws the decay
+ * curve as an SVG path; pure client-side (`Decay::value` ported in JS).
+ *
+ * RBAC: list/show ⇒ Viewer; write ⇒ Admin.
+ */
+final class CategoriesController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        try {
+            $list = $this->admin->listCategories($user->userId);
+        } catch (ApiException $e) {
+            $list = ['items' => [], 'total' => 0];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/categories/index.twig', [
+            'active_section' => 'categories',
+            'list' => $list,
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function edit(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Category not found',
+            ]);
+        }
+        try {
+            $category = $this->admin->getCategory($user->userId, $id);
+        } catch (ApiNotFoundException) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Category not found',
+            ]);
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+
+            return $response->withStatus(303)->withHeader('Location', '/app/categories');
+        }
+
+        return $this->twigEngine->render($response, 'pages/categories/edit.twig', [
+            'active_section' => 'categories',
+            'category' => $category,
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $payload = $this->payloadFromForm($this->formBody($request), true);
+
+        try {
+            $created = $this->admin->createCategory($user->userId, $payload);
+            $this->sessionManager->flash('success', 'Category created.');
+            $newId = (int) ($created['id'] ?? 0);
+
+            return $response->withStatus(303)->withHeader('Location', $newId > 0 ? '/app/categories/' . $newId : '/app/categories');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/categories');
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/categories');
+        }
+
+        $payload = $this->payloadFromForm($this->formBody($request), false);
+
+        try {
+            $this->admin->updateCategory($user->userId, $id, $payload);
+            $this->sessionManager->flash('success', 'Category saved.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/categories/' . $id);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/categories');
+        }
+        try {
+            $this->admin->deleteCategory($user->userId, $id);
+            $this->sessionManager->flash('success', 'Category deleted.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/categories');
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    private function payloadFromForm(array $body, bool $forCreate): array
+    {
+        $payload = [];
+        if ($forCreate) {
+            $payload['slug'] = isset($body['slug']) && is_string($body['slug']) ? trim($body['slug']) : '';
+        } elseif (isset($body['slug']) && is_string($body['slug'])) {
+            $payload['slug'] = trim($body['slug']);
+        }
+        if (isset($body['name']) && is_string($body['name'])) {
+            $payload['name'] = trim($body['name']);
+        }
+        if (array_key_exists('description', $body)) {
+            $payload['description'] = is_string($body['description']) && trim($body['description']) !== ''
+                ? trim($body['description'])
+                : null;
+        }
+        if (isset($body['decay_function']) && is_string($body['decay_function'])) {
+            $payload['decay_function'] = trim($body['decay_function']);
+        }
+        if (isset($body['decay_param']) && is_numeric($body['decay_param'])) {
+            $payload['decay_param'] = (float) $body['decay_param'];
+        }
+        $payload['is_active'] = $this->formBool($body['is_active'] ?? true);
+
+        return $payload;
+    }
+}

+ 206 - 0
ui/src/Controllers/ConsumersController.php

@@ -0,0 +1,206 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\ApiClient\ApiNotFoundException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/consumers` — list, edit, create, soft-delete. Edit page exposes
+ * a policy dropdown populated from `listPolicies()`.
+ *
+ * RBAC: list/show ⇒ Viewer; write ⇒ Admin.
+ */
+final class ConsumersController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        try {
+            $list = $this->admin->listConsumers($user->userId);
+            $policiesRaw = $this->admin->listPolicies($user->userId);
+        } catch (ApiException $e) {
+            $list = ['data' => [], 'total' => 0];
+            $policiesRaw = ['items' => []];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/consumers/index.twig', [
+            'active_section' => 'consumers',
+            'list' => $list,
+            'policies' => $policiesRaw['items'] ?? [],
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function edit(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Consumer not found',
+            ]);
+        }
+        try {
+            $consumer = $this->admin->getConsumer($user->userId, $id);
+            $policiesRaw = $this->admin->listPolicies($user->userId);
+        } catch (ApiNotFoundException) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Consumer not found',
+            ]);
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+
+            return $response->withStatus(303)->withHeader('Location', '/app/consumers');
+        }
+
+        return $this->twigEngine->render($response, 'pages/consumers/edit.twig', [
+            'active_section' => 'consumers',
+            'consumer' => $consumer,
+            'policies' => $policiesRaw['items'] ?? [],
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $body = $this->formBody($request);
+        $payload = [
+            'name' => isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '',
+        ];
+        if (isset($body['description']) && is_string($body['description'])) {
+            $payload['description'] = trim($body['description']) === '' ? null : trim($body['description']);
+        }
+        if (isset($body['policy_id']) && (string) $body['policy_id'] !== '' && ctype_digit((string) $body['policy_id'])) {
+            $payload['policy_id'] = (int) $body['policy_id'];
+        }
+
+        try {
+            $created = $this->admin->createConsumer($user->userId, $payload);
+            $this->sessionManager->flash('success', 'Consumer created.');
+            $newId = (int) ($created['id'] ?? 0);
+
+            return $response->withStatus(303)->withHeader('Location', $newId > 0 ? '/app/consumers/' . $newId : '/app/consumers');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/consumers');
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/consumers');
+        }
+        $body = $this->formBody($request);
+        $payload = [];
+        if (isset($body['name']) && is_string($body['name'])) {
+            $payload['name'] = trim($body['name']);
+        }
+        if (isset($body['description']) && is_string($body['description'])) {
+            $payload['description'] = trim($body['description']) === '' ? null : trim($body['description']);
+        }
+        if (isset($body['policy_id']) && (string) $body['policy_id'] !== '' && ctype_digit((string) $body['policy_id'])) {
+            $payload['policy_id'] = (int) $body['policy_id'];
+        }
+        if (array_key_exists('is_active', $body)) {
+            $payload['is_active'] = $this->formBool($body['is_active']);
+        }
+
+        try {
+            $this->admin->updateConsumer($user->userId, $id, $payload);
+            $this->sessionManager->flash('success', 'Consumer saved.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/consumers/' . $id);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/consumers');
+        }
+        try {
+            $this->admin->deleteConsumer($user->userId, $id);
+            $this->sessionManager->flash('success', 'Consumer deactivated.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/consumers');
+    }
+}

+ 116 - 0
ui/src/Controllers/CrudControllerSupport.php

@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\ApiAuthException;
+use App\ApiClient\ApiException;
+use App\ApiClient\ApiNotFoundException;
+use App\ApiClient\ApiValidationException;
+use App\Auth\SessionManager;
+use App\Auth\UserContext;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * Common helpers for the M10 admin CRUD controllers: session-required
+ * gate, parsed-body extraction, RBAC check helpers, and an exception →
+ * flash mapper that turns api errors into user-readable messages while
+ * still letting the form re-render with field-level details.
+ */
+trait CrudControllerSupport
+{
+    abstract protected function sessions(): SessionManager;
+
+    abstract protected function twig(): Twig;
+
+    /**
+     * Gate every /app/* admin page on a logged-in user. Returns a 303
+     * redirect (or null when the user is logged in).
+     */
+    protected function requireUser(ServerRequestInterface $request, ResponseInterface $response): ?ResponseInterface
+    {
+        $user = $this->sessions()->getUser();
+        if ($user !== null) {
+            return null;
+        }
+
+        return $response->withStatus(302)->withHeader('Location', '/login');
+    }
+
+    /**
+     * The session-cached role + a helper to gate UI render branches on
+     * permissions. Treat as cosmetic; the api enforces RBAC.
+     */
+    protected function userIs(UserContext $user, string ...$allowedRoles): bool
+    {
+        return in_array($user->role, $allowedRoles, true);
+    }
+
+    /**
+     * Parse a form-encoded body. CsrfMiddleware guards the request, so
+     * the token is already validated by the time we read fields.
+     *
+     * @return array<string, mixed>
+     */
+    protected function formBody(ServerRequestInterface $request): array
+    {
+        $body = $request->getParsedBody();
+
+        return is_array($body) ? $body : [];
+    }
+
+    /**
+     * Map an api exception to a flash message. Validation errors are
+     * surfaced inline by the caller (it stashes details in flash); other
+     * exceptions render a single-line flash so the user knows the
+     * action failed.
+     */
+    protected function flashFromException(ApiException $e): void
+    {
+        if ($e instanceof ApiValidationException) {
+            $details = [];
+            foreach ($e->details as $field => $msg) {
+                $details[] = sprintf('%s: %s', $field, $msg);
+            }
+            $message = $details === [] ? 'validation failed' : implode(', ', $details);
+            $this->sessions()->flash('error', $message);
+
+            return;
+        }
+        if ($e instanceof ApiAuthException) {
+            $this->sessions()->flash('error', 'Forbidden — your role doesn\'t allow this.');
+
+            return;
+        }
+        if ($e instanceof ApiNotFoundException) {
+            $this->sessions()->flash('error', 'Not found.');
+
+            return;
+        }
+        $this->sessions()->flash('error', 'API error: ' . $e->getMessage());
+    }
+
+    protected function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+
+    /**
+     * Convert form-typed booleans (`'1'`, `'true'`, `'on'`) to PHP true.
+     * Anything else (including absent) is false.
+     */
+    protected function formBool(mixed $v): bool
+    {
+        if (is_bool($v)) {
+            return $v;
+        }
+        if (is_string($v)) {
+            return in_array(strtolower($v), ['1', 'true', 'on', 'yes'], true);
+        }
+
+        return false;
+    }
+}

+ 1 - 0
ui/src/Controllers/IpsController.php

@@ -108,6 +108,7 @@ final class IpsController
         return $this->twig->render($response, 'pages/ips/detail.twig', [
             'active_section' => 'ips',
             'detail' => $detail,
+            'can_write' => in_array($user->role, ['operator', 'admin'], true),
         ]);
     }
 

+ 165 - 0
ui/src/Controllers/ManualBlocksController.php

@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/manual-blocks` and `/app/subnets` — the latter is a thin alias
+ * that pre-filters to `kind=subnet` so the sidebar's "Subnets" link
+ * lands somewhere meaningful.
+ *
+ * RBAC mirrors the api: list/show ⇒ Viewer, create/delete ⇒ Operator.
+ * Buttons hide for viewers; the api enforces.
+ */
+final class ManualBlocksController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        // requireUser already null-checked the user; assignment for type narrow.
+        \assert($user !== null);
+
+        $kind = $request->getQueryParams()['kind'] ?? null;
+        if (!is_string($kind) || !in_array($kind, ['ip', 'subnet'], true)) {
+            $kind = null;
+        }
+
+        try {
+            $list = $this->admin->listManualBlocks($user->userId, $kind);
+        } catch (ApiException $e) {
+            $list = ['items' => [], 'total' => 0];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/manual-blocks/index.twig', [
+            'active_section' => 'manual-blocks',
+            'list' => $list,
+            'kind' => $kind,
+            'can_write' => $this->userIs($user, 'operator', 'admin'),
+        ]);
+    }
+
+    public function subnetsIndex(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        try {
+            $list = $this->admin->listManualBlocks($user->userId, 'subnet');
+        } catch (ApiException $e) {
+            $list = ['items' => [], 'total' => 0];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/manual-blocks/index.twig', [
+            'active_section' => 'subnets',
+            'list' => $list,
+            'kind' => 'subnet',
+            'can_write' => $this->userIs($user, 'operator', 'admin'),
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $body = $this->formBody($request);
+        $kind = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
+        $payload = ['kind' => $kind];
+        if ($kind === 'ip' && isset($body['ip']) && is_string($body['ip'])) {
+            $payload['ip'] = trim($body['ip']);
+        }
+        if ($kind === 'subnet' && isset($body['cidr']) && is_string($body['cidr'])) {
+            $payload['cidr'] = trim($body['cidr']);
+        }
+        if (isset($body['reason']) && is_string($body['reason']) && trim($body['reason']) !== '') {
+            $payload['reason'] = trim($body['reason']);
+        }
+        if (isset($body['expires_at']) && is_string($body['expires_at']) && trim($body['expires_at']) !== '') {
+            // HTML datetime-local emits "YYYY-MM-DDTHH:MM"; the api accepts ISO-8601.
+            $payload['expires_at'] = trim($body['expires_at']) . 'Z';
+        }
+
+        try {
+            $this->admin->createManualBlock($user->userId, $payload);
+            $this->sessionManager->flash('success', 'Manual block added.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        // Always redirect to /app/manual-blocks. Users can navigate
+        // back to /app/subnets via the sidebar or kind filter; routing
+        // POST→GET to a stable destination keeps tests + browser behaviour
+        // predictable.
+        return $response->withStatus(303)->withHeader('Location', '/app/manual-blocks');
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/manual-blocks');
+        }
+
+        try {
+            $this->admin->deleteManualBlock($user->userId, $id);
+            $this->sessionManager->flash('success', 'Manual block removed.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        $next = $this->formBody($request)['next'] ?? '/app/manual-blocks';
+
+        return $response->withStatus(303)->withHeader('Location', is_string($next) && $next !== '' ? $next : '/app/manual-blocks');
+    }
+}

+ 269 - 0
ui/src/Controllers/PoliciesController.php

@@ -0,0 +1,269 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\ApiClient\ApiNotFoundException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/policies` — list, edit (threshold matrix), create, delete.
+ *
+ * Threshold matrix editing happens on the edit page: rows = categories,
+ * columns = ["threshold"]. The page POSTs a flat form of slug→value
+ * pairs; we resolve those into the api's `thresholds: {slug: number}`
+ * shape. Empty values mean "remove from policy".
+ *
+ * Live preview is driven by an htmx fetch from the edit page directly
+ * to `/api/v1/admin/policies/{id}/preview` via the same session token.
+ *
+ * RBAC: list/show/preview ⇒ Viewer; write ⇒ Admin.
+ */
+final class PoliciesController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    /**
+     * Browser-side preview proxy: the policy edit page's Alpine
+     * component fetches `/app/policies/{id}/preview-proxy`; this
+     * controller forwards to the api with the user's session
+     * impersonation. Returns the api's preview JSON verbatim.
+     *
+     * @param array{id: string} $args
+     */
+    public function previewProxy(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $user = $this->sessionManager->getUser();
+        if ($user === null) {
+            $response->getBody()->write((string) json_encode(['error' => 'unauthenticated']));
+
+            return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
+        }
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            $response->getBody()->write((string) json_encode(['error' => 'not_found']));
+
+            return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
+        }
+        try {
+            $payload = $this->admin->previewPolicy($user->userId, $id);
+        } catch (ApiException $e) {
+            $response->getBody()->write((string) json_encode(['error' => $e->getMessage()]));
+
+            return $response->withStatus(502)->withHeader('Content-Type', 'application/json');
+        }
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response->withStatus(200)->withHeader('Content-Type', 'application/json');
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        try {
+            $list = $this->admin->listPolicies($user->userId);
+        } catch (ApiException $e) {
+            $list = ['items' => [], 'total' => 0];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/policies/index.twig', [
+            'active_section' => 'policies',
+            'list' => $list,
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function edit(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Policy not found',
+            ]);
+        }
+
+        try {
+            $policy = $this->admin->getPolicy($user->userId, $id);
+            $categories = $this->admin->listCategories($user->userId);
+        } catch (ApiNotFoundException) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Policy not found',
+            ]);
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+
+            return $response->withStatus(303)->withHeader('Location', '/app/policies');
+        }
+
+        return $this->twigEngine->render($response, 'pages/policies/edit.twig', [
+            'active_section' => 'policies',
+            'policy' => $policy,
+            'categories' => $categories['items'] ?? [],
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $body = $this->formBody($request);
+        $payload = $this->payloadFromForm($body);
+
+        try {
+            $created = $this->admin->createPolicy($user->userId, $payload);
+            $this->sessionManager->flash('success', 'Policy created.');
+            $newId = (int) ($created['id'] ?? 0);
+
+            return $response->withStatus(303)->withHeader('Location', $newId > 0 ? '/app/policies/' . $newId : '/app/policies');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/policies');
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/policies');
+        }
+
+        $body = $this->formBody($request);
+        $payload = $this->payloadFromForm($body);
+
+        try {
+            $this->admin->updatePolicy($user->userId, $id, $payload);
+            $this->sessionManager->flash('success', 'Policy saved.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/policies/' . $id);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/policies');
+        }
+        try {
+            $this->admin->deletePolicy($user->userId, $id);
+            $this->sessionManager->flash('success', 'Policy deleted.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/policies');
+    }
+
+    /**
+     * Translate the edit-form payload to the api's body shape:
+     *   thresholds[<slug>]=number → {<slug>: number}
+     *   include_manual_blocks="on" → true (checkbox)
+     *
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    private function payloadFromForm(array $body): array
+    {
+        $payload = [];
+        if (isset($body['name']) && is_string($body['name'])) {
+            $payload['name'] = trim($body['name']);
+        }
+        if (isset($body['description']) && is_string($body['description'])) {
+            $payload['description'] = trim($body['description']) === '' ? null : trim($body['description']);
+        }
+        $payload['include_manual_blocks'] = $this->formBool($body['include_manual_blocks'] ?? null);
+
+        $thresholds = [];
+        if (isset($body['thresholds']) && is_array($body['thresholds'])) {
+            foreach ($body['thresholds'] as $slug => $value) {
+                if (!is_string($slug) || $slug === '') {
+                    continue;
+                }
+                if (is_string($value)) {
+                    $value = trim($value);
+                }
+                if ($value === '' || $value === null) {
+                    continue; // empty = "not in policy"
+                }
+                if (is_numeric($value)) {
+                    $thresholds[$slug] = (float) $value;
+                }
+            }
+        }
+        if ($thresholds !== []) {
+            $payload['thresholds'] = $thresholds;
+        }
+
+        return $payload;
+    }
+}

+ 199 - 0
ui/src/Controllers/ReportersController.php

@@ -0,0 +1,199 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\ApiClient\ApiNotFoundException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/reporters` — list, edit, create, soft-delete.
+ *
+ * RBAC: list/show ⇒ Viewer; write ⇒ Admin.
+ */
+final class ReportersController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        try {
+            $list = $this->admin->listReporters($user->userId);
+        } catch (ApiException $e) {
+            $list = ['data' => [], 'total' => 0];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/reporters/index.twig', [
+            'active_section' => 'reporters',
+            'list' => $list,
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function edit(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Reporter not found',
+            ]);
+        }
+        try {
+            $reporter = $this->admin->getReporter($user->userId, $id);
+        } catch (ApiNotFoundException) {
+            return $this->twigEngine->render($response->withStatus(404), 'pages/error.twig', [
+                'status' => 404, 'is_client_error' => true, 'message' => 'Reporter not found',
+            ]);
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+
+            return $response->withStatus(303)->withHeader('Location', '/app/reporters');
+        }
+
+        return $this->twigEngine->render($response, 'pages/reporters/edit.twig', [
+            'active_section' => 'reporters',
+            'reporter' => $reporter,
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $body = $this->formBody($request);
+        $payload = ['name' => isset($body['name']) && is_string($body['name']) ? trim($body['name']) : ''];
+        if (isset($body['description']) && is_string($body['description'])) {
+            $payload['description'] = trim($body['description']) === '' ? null : trim($body['description']);
+        }
+        if (isset($body['trust_weight']) && is_numeric($body['trust_weight'])) {
+            $payload['trust_weight'] = (float) $body['trust_weight'];
+        }
+
+        try {
+            $created = $this->admin->createReporter($user->userId, $payload);
+            $this->sessionManager->flash('success', 'Reporter created.');
+            $newId = (int) ($created['id'] ?? 0);
+
+            return $response->withStatus(303)->withHeader('Location', $newId > 0 ? '/app/reporters/' . $newId : '/app/reporters');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/reporters');
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/reporters');
+        }
+
+        $body = $this->formBody($request);
+        $payload = [];
+        if (isset($body['name']) && is_string($body['name'])) {
+            $payload['name'] = trim($body['name']);
+        }
+        if (isset($body['description']) && is_string($body['description'])) {
+            $payload['description'] = trim($body['description']) === '' ? null : trim($body['description']);
+        }
+        if (isset($body['trust_weight']) && is_numeric($body['trust_weight'])) {
+            $payload['trust_weight'] = (float) $body['trust_weight'];
+        }
+        if (array_key_exists('is_active', $body)) {
+            $payload['is_active'] = $this->formBool($body['is_active']);
+        }
+
+        try {
+            $this->admin->updateReporter($user->userId, $id, $payload);
+            $this->sessionManager->flash('success', 'Reporter saved.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/reporters/' . $id);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/reporters');
+        }
+        try {
+            $this->admin->deleteReporter($user->userId, $id);
+            $this->sessionManager->flash('success', 'Reporter deactivated.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/reporters');
+    }
+}

+ 149 - 0
ui/src/Controllers/TokensController.php

@@ -0,0 +1,149 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/tokens` — list, create (with one-time raw-token modal), revoke.
+ *
+ * One-time-display flow:
+ *  - POST /app/tokens calls AdminClient::createToken(); api returns the
+ *    raw token in the response body.
+ *  - We stash the raw token in the session under `_token_just_created`
+ *    and 303 to /app/tokens.
+ *  - The list view consumes the flash on render and shows it inside an
+ *    Alpine modal that can't be re-opened. Refreshing the page clears
+ *    the modal — the user must copy now or revoke and reissue.
+ *
+ * RBAC: list / create / revoke ⇒ Admin.
+ */
+final class TokensController
+{
+    use CrudControllerSupport;
+
+    private const SESSION_KEY = '_token_just_created';
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        try {
+            $list = $this->admin->listTokens($user->userId);
+            $reportersRaw = $this->admin->listReporters($user->userId);
+            $consumersRaw = $this->admin->listConsumers($user->userId);
+        } catch (ApiException $e) {
+            $list = ['data' => [], 'total' => 0];
+            $reportersRaw = ['data' => []];
+            $consumersRaw = ['data' => []];
+            $this->flashFromException($e);
+        }
+
+        // One-time raw-token consumption: pop and forget.
+        $justCreated = $_SESSION[self::SESSION_KEY] ?? null;
+        unset($_SESSION[self::SESSION_KEY]);
+
+        return $this->twigEngine->render($response, 'pages/tokens/index.twig', [
+            'active_section' => 'tokens',
+            'list' => $list,
+            'just_created' => is_array($justCreated) ? $justCreated : null,
+            'reporters' => $reportersRaw['data'] ?? [],
+            'consumers' => $consumersRaw['data'] ?? [],
+            'can_write' => $this->userIs($user, 'admin'),
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $body = $this->formBody($request);
+        $kind = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
+        $payload = ['kind' => $kind];
+        if ($kind === 'reporter' && isset($body['reporter_id']) && ctype_digit((string) $body['reporter_id'])) {
+            $payload['reporter_id'] = (int) $body['reporter_id'];
+        }
+        if ($kind === 'consumer' && isset($body['consumer_id']) && ctype_digit((string) $body['consumer_id'])) {
+            $payload['consumer_id'] = (int) $body['consumer_id'];
+        }
+        if ($kind === 'admin' && isset($body['role']) && is_string($body['role'])) {
+            $payload['role'] = trim($body['role']);
+        }
+
+        try {
+            $created = $this->admin->createToken($user->userId, $payload);
+            // Stash the raw token + minimal context for one-time display.
+            $_SESSION[self::SESSION_KEY] = [
+                'raw_token' => (string) ($created['raw_token'] ?? ''),
+                'kind' => (string) ($created['kind'] ?? $kind),
+                'token_prefix' => (string) ($created['token_prefix'] ?? ''),
+                'id' => (int) ($created['id'] ?? 0),
+            ];
+            $this->sessionManager->flash('success', 'Token created. Copy it now — it won\'t be shown again.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/tokens');
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/tokens');
+        }
+        try {
+            $this->admin->deleteToken($user->userId, $id);
+            $this->sessionManager->flash('success', 'Token revoked.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/tokens');
+    }
+}

+ 304 - 0
ui/tests/Integration/Crud/CrudPagesTest.php

@@ -0,0 +1,304 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Crud;
+
+use App\Auth\UserContext;
+use App\Http\CsrfMiddleware;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * SPEC §M10 acceptance: every list page renders and at least one
+ * happy-path + one validation-error path per resource is covered.
+ *
+ * Each test queues responses for the AdminClient calls the controller
+ * fires; the AppTestCase's MockHandler returns them in FIFO order.
+ */
+final class CrudPagesTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        $this->bootApp();
+        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
+        $_SESSION['_last_active'] = time();
+        $_SESSION['_authenticated_at'] = time();
+    }
+
+    // ---- list pages render ----
+
+    public function testManualBlocksListRenders(): void
+    {
+        $this->enqueueApiResponse(200, ['items' => [
+            ['id' => 1, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'edge', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
+        ], 'total' => 1]);
+
+        $response = $this->request('GET', '/app/manual-blocks');
+        self::assertSame(200, $response->getStatusCode());
+        $body = (string) $response->getBody();
+        self::assertStringContainsString('192.0.2.0/24', $body);
+        self::assertStringContainsString('Manual blocks', $body);
+    }
+
+    public function testSubnetsAliasFiltersToSubnets(): void
+    {
+        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
+
+        $response = $this->request('GET', '/app/subnets');
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('Subnets', (string) $response->getBody());
+    }
+
+    public function testAllowlistListRenders(): void
+    {
+        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
+
+        $response = $this->request('GET', '/app/allowlist');
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('Allowlist', (string) $response->getBody());
+    }
+
+    public function testPoliciesListRenders(): void
+    {
+        $this->enqueueApiResponse(200, ['items' => [
+            ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
+        ], 'total' => 1]);
+
+        $response = $this->request('GET', '/app/policies');
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('moderate', (string) $response->getBody());
+    }
+
+    public function testReportersListRenders(): void
+    {
+        $this->enqueueApiResponse(200, ['data' => [
+            ['id' => 1, 'name' => 'web-prod-01', 'description' => 'edge', 'trust_weight' => 1.0, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z'],
+        ], 'total' => 1, 'page' => 1, 'limit' => 200]);
+
+        $response = $this->request('GET', '/app/reporters');
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('web-prod-01', (string) $response->getBody());
+    }
+
+    public function testConsumersListRenders(): void
+    {
+        // First call: listConsumers; second: listPolicies (for the dropdown).
+        $this->enqueueApiResponse(200, ['data' => [
+            ['id' => 1, 'name' => 'fw-1', 'policy_id' => 5, 'description' => null, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z', 'last_pulled_at' => null],
+        ], 'total' => 1, 'page' => 1, 'limit' => 200]);
+        $this->enqueueApiResponse(200, ['items' => [
+            ['id' => 5, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
+        ], 'total' => 1]);
+
+        $response = $this->request('GET', '/app/consumers');
+        self::assertSame(200, $response->getStatusCode());
+        $body = (string) $response->getBody();
+        self::assertStringContainsString('fw-1', $body);
+        self::assertStringContainsString('moderate', $body);
+    }
+
+    public function testTokensListRenders(): void
+    {
+        // listTokens, listReporters, listConsumers in that order.
+        $this->enqueueApiResponse(200, ['data' => [
+            ['id' => 1, 'kind' => 'admin', 'token_prefix' => 'irdb_adm', 'role' => 'viewer', 'reporter_id' => null, 'consumer_id' => null, 'expires_at' => null, 'revoked_at' => null, 'last_used_at' => null, 'created_at' => '2026-04-29T10:00:00Z'],
+        ], 'total' => 1, 'page' => 1, 'limit' => 200]);
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+
+        $response = $this->request('GET', '/app/tokens');
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('irdb_adm', (string) $response->getBody());
+    }
+
+    public function testCategoriesListRenders(): void
+    {
+        $this->enqueueApiResponse(200, ['items' => [
+            ['id' => 1, 'slug' => 'brute_force', 'name' => 'Brute force', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
+        ], 'total' => 1]);
+
+        $response = $this->request('GET', '/app/categories');
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('brute_force', (string) $response->getBody());
+    }
+
+    // ---- happy + validation paths per resource ----
+
+    public function testManualBlockCreateHappyPath(): void
+    {
+        $this->enqueueApiResponse(201, ['id' => 99, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24']);
+        $token = $this->csrfFromManualBlocks();
+
+        $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'test']);
+        $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
+
+        self::assertSame(303, $response->getStatusCode());
+        self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location'));
+        $flash = $_SESSION['_flash'] ?? [];
+        self::assertNotEmpty($flash);
+        self::assertSame('success', $flash[0]['type']);
+    }
+
+    public function testManualBlockCreateValidationErrorFlashesField(): void
+    {
+        // The MockHandler is FIFO: the GET inside csrfFromManualBlocks
+        // would consume any earlier-queued response, so queue the 400
+        // AFTER the helper's GET has been served.
+        $token = $this->csrfFromManualBlocks();
+        $this->enqueueApiResponse(400, [
+            'error' => 'validation_failed',
+            'details' => ['cidr' => 'invalid format'],
+        ]);
+
+        $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => 'not-a-cidr']);
+        $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
+
+        self::assertSame(303, $response->getStatusCode());
+        $flash = $_SESSION['_flash'] ?? [];
+        self::assertNotEmpty($flash);
+        self::assertSame('error', $flash[0]['type']);
+        self::assertStringContainsString('cidr', $flash[0]['message']);
+    }
+
+    public function testTokenCreateStashesRawTokenForOneTimeDisplay(): void
+    {
+        // GET /app/tokens warm-up to set CSRF + session.
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $this->request('GET', '/app/tokens');
+        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
+        self::assertNotEmpty($token);
+
+        // POST creates a new admin token.
+        $this->enqueueApiResponse(201, [
+            'id' => 42, 'kind' => 'admin', 'token_prefix' => 'irdb_adm',
+            'raw_token' => 'irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+            'role' => 'viewer',
+        ]);
+        $body = http_build_query(['csrf_token' => $token, 'kind' => 'admin', 'role' => 'viewer']);
+        $createResp = $this->request('POST', '/app/tokens', [], $body, 'application/x-www-form-urlencoded');
+        self::assertSame(303, $createResp->getStatusCode());
+
+        // The follow-up GET surfaces the raw token in the response body
+        // and clears the session slot afterwards.
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $listResp = $this->request('GET', '/app/tokens');
+
+        $html = (string) $listResp->getBody();
+        self::assertStringContainsString('irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $html);
+        self::assertArrayNotHasKey('_token_just_created', $_SESSION);
+    }
+
+    public function testCategoryCreateValidationFlash(): void
+    {
+        // Warm-up GET for CSRF token.
+        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
+        $this->request('GET', '/app/categories');
+        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
+
+        $this->enqueueApiResponse(400, [
+            'error' => 'validation_failed',
+            'details' => ['slug' => 'already exists'],
+        ]);
+        $body = http_build_query([
+            'csrf_token' => $token,
+            'slug' => 'brute_force',
+            'name' => 'Dup',
+            'decay_function' => 'exponential',
+            'decay_param' => '14',
+        ]);
+        $response = $this->request('POST', '/app/categories', [], $body, 'application/x-www-form-urlencoded');
+
+        self::assertSame(303, $response->getStatusCode());
+        self::assertSame('/app/categories', $response->getHeaderLine('Location'));
+        $flash = $_SESSION['_flash'] ?? [];
+        self::assertNotEmpty($flash);
+        self::assertStringContainsString('slug', $flash[0]['message']);
+    }
+
+    public function testPolicyDeleteForwardsToApi(): void
+    {
+        $this->enqueueApiResponse(200, ['items' => [
+            ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
+        ], 'total' => 1]);
+        $this->request('GET', '/app/policies');
+        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
+
+        $this->enqueueApiResponse(204, []);
+        $body = http_build_query(['csrf_token' => $token]);
+        $response = $this->request('POST', '/app/policies/1/delete', [], $body, 'application/x-www-form-urlencoded');
+
+        self::assertSame(303, $response->getStatusCode());
+        self::assertSame('/app/policies', $response->getHeaderLine('Location'));
+    }
+
+    public function testReporterCreateHappyPath(): void
+    {
+        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
+        $this->request('GET', '/app/reporters');
+        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
+
+        $this->enqueueApiResponse(201, ['id' => 17, 'name' => 'test-rep', 'trust_weight' => 1.0, 'is_active' => true]);
+        $body = http_build_query(['csrf_token' => $token, 'name' => 'test-rep', 'trust_weight' => '1.0']);
+        $response = $this->request('POST', '/app/reporters', [], $body, 'application/x-www-form-urlencoded');
+
+        self::assertSame(303, $response->getStatusCode());
+        self::assertSame('/app/reporters/17', $response->getHeaderLine('Location'));
+    }
+
+    public function testIpDetailRendersActionButtonsForOperator(): void
+    {
+        $_SESSION['_user'] = (new UserContext(1, 'Op', 'operator', null, UserContext::SOURCE_LOCAL))->toArray();
+        $this->enqueueApiResponse(200, [
+            'ip' => '203.0.113.10',
+            'is_ipv4' => true,
+            'status' => 'scored',
+            'scores' => [],
+            'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
+            'manual_block' => null,
+            'allowlist' => null,
+            'history' => [],
+            'has_more' => false,
+        ]);
+
+        $response = $this->request('GET', '/app/ips/203.0.113.10');
+        $body = (string) $response->getBody();
+        self::assertStringContainsString('Add to allowlist', $body);
+        self::assertStringContainsString('Manually block', $body);
+    }
+
+    public function testIpDetailHidesActionButtonsForViewer(): void
+    {
+        $_SESSION['_user'] = (new UserContext(2, 'View', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray();
+        $this->enqueueApiResponse(200, [
+            'ip' => '203.0.113.10',
+            'is_ipv4' => true,
+            'status' => 'scored',
+            'scores' => [],
+            'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
+            'manual_block' => null,
+            'allowlist' => null,
+            'history' => [],
+            'has_more' => false,
+        ]);
+
+        $response = $this->request('GET', '/app/ips/203.0.113.10');
+        $body = (string) $response->getBody();
+        self::assertStringNotContainsString('Add to allowlist', $body);
+        self::assertStringNotContainsString('Manually block', $body);
+    }
+
+    private function csrfFromManualBlocks(): string
+    {
+        // GET to set the CSRF token in the session.
+        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
+        $this->request('GET', '/app/manual-blocks');
+        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
+        self::assertNotEmpty($token);
+
+        return $token;
+    }
+}