Skip to content

feat(admin): scoped user-management with approval queue (#64, #85)#91

Open
martinydeAI wants to merge 7 commits into
developfrom
feature/issue-85-admin-user-management
Open

feat(admin): scoped user-management with approval queue (#64, #85)#91
martinydeAI wants to merge 7 commits into
developfrom
feature/issue-85-admin-user-management

Conversation

@martinydeAI

@martinydeAI martinydeAI commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Links to issues

Closes #64.
Closes #85.

Implements PR 5 of the User management milestone plan.

! This pr should not be handled before other User management PRs have been merged to keep a proper comparison.

do-not-merge — depends on PR #87 (#84) AND PR #86 (#45 + #83).

This branch is feature/issue-84-roles-and-voter (PR #87) with
feature/issue-45-user-entity-extension (PR #86) merged in. The
branch needs PR #87's voter + EmailDomain helper AND PR #86's
User.status field. Once both PRs land:

  1. Rebase this branch onto fresh develop (drop the merge commit,
    keep the feature commit on top).
  2. Remove the do-not-merge label.
  3. Drop the block reason from the body.

Description

Admin user-management surface:

  • GET /admin/users — list view scoped by role. Admins see every
    user, domain managers see only same-domain users (via the
    EmailDomain helper). Optional ?status=pending|approved|blocked
    filter powers the approval queue (feat: approval queue for domain managers #64).
  • GET /admin/users/pending — sugar route, 302 to
    /admin/users?status=pending so feat: approval queue for domain managers #64's URL keeps working.
  • POST /admin/users/{id}/approve and .../block — flip the user's
    status. Gated by IsGranted('APPROVE_USER' / 'BLOCK_USER', subject: $user) so PR feat(security): ROLE_DOMAIN_MANAGER, role hierarchy, domain-scoped voter (#84) #87's voter fires on every transition. CSRF-protected
    with the admin-user-action intent token.
  • App\Security\UserApproval — single home for the
    Pending -> Approved / Approved -> Blocked transitions.
  • App\Repository\UserRepository::findVisibleTo() — scoped finder
    with optional status filter. Domain managers without an email
    defensively get an empty result (defence in depth — the firewall
    • voter would already block, but the repository falls through
      rather than running a query against an unresolved domain).
  • ^/admin access_control rule + per-action IsGranted
    belt-and-braces.
  • Template re-uses Form/Button, Form/Label, Form/TextInput,
    Eyebrow, and base layout — no new component families.
  • back parameter on the action forms only honours /admin/users…
    URLs (hostile-redirect guard).

Screenshot of the result

n/a — UI matches the existing layout primitives. Manual screenshot
can be added on request.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

Verified locally:

  • task coding-standards-check — all green.
  • task test-coverage — 85 tests, 252 assertions, 100 %
    coverage
    .

Additional comments or questions

The "domain manager promotion" UI is not in scope. Granting a
user ROLE_DOMAIN_MANAGER (or revoking it) is still done via the
console for now — separate UX decision, not tracked as its own issue
yet.

The voter's IsGranted runs before the controller body, so a
cross-domain POST yields 403 from the voter — never reaching the
CSRF check. Both paths are exercised by the test suite.

#12 (broader RBAC meta) can be closed when this PR lands per the
plan — its concrete payload is now the role set in #84 + the scoped
admin access here.


Details - AI specificities

martinydeAI and others added 2 commits June 19, 2026 10:29
Adds the admin user-management surface decided in ADR 006:

- `App\Controller\Admin\UserController` exposes `/admin/users` (list),
  `/admin/users/pending` (redirects to the pending-filtered list), and
  POST-only `/admin/users/{id}/{approve|block}` actions. Class-level
  `IsGranted('ROLE_DOMAIN_MANAGER')` plus per-action
  `IsGranted('APPROVE_USER'|'BLOCK_USER', subject: $user)` gates
  through PR 2's `ManageUserVoter` so the same-domain check fires
  on every state transition.
- `App\Security\UserApproval` owns the `Pending -> Approved` and
  `Approved -> Blocked` transitions (and their reverses). One place
  to add audit logging later if needed.
- `App\Repository\UserRepository::findVisibleTo()` scopes the list
  query: admins see every user, domain managers see only same-domain
  users via `LOWER(email) LIKE '%@<domain>'`, anyone else sees an
  empty result. Optional status filter via the `UserStatus` enum so
  `/admin/users?status=pending` powers the approval-queue subset.
- `templates/admin/user/list.html.twig` renders the table with the
  existing `Form/Button` + `Eyebrow` components and per-row action
  forms. CSRF-protected via Symfony's `csrf_token('admin-user-action')`
  helper; the `back` parameter on each action only honours `/admin/users…`
  URLs so a hostile actor can't redirect off-site.
- `security.yaml` adds an `^/admin` access-control rule as a second
  layer in front of the controller `IsGranted`.

Sequenced as PR 5 of the User management milestone plan. Branched
from PR 2 (#87, voter + Roles + EmailDomain) and merged in PR 1
(#86, User.name + User.status) since both are needed; labelled
`do-not-merge` until both bases land. Closes #12's broader RBAC
umbrella along with the concrete sibling closure plan.

Closes #64.
Closes #85.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@martinydeAI martinydeAI added the do-not-merge Block merging until external dependency lands label Jun 19, 2026
Removed references to ADR 006 and added clarity to column descriptions.
@martinyde martinyde removed the do-not-merge Block merging until external dependency lands label Jun 22, 2026
@martinyde martinyde changed the base branch from feature/issue-84-roles-and-voter to develop June 22, 2026 12:34
martinyde
martinyde previously approved these changes Jun 22, 2026
Comment thread migrations/Version20260619080000.php
The admin user-list page hand-rolled the same flash-message box
markup that the new `<twig:Alert>` component (from PR #99, now on
develop) handles. Swap to the component so the markup and ARIA
role live in one place.

`type="success"` resolves to `role="status"` inside the component,
matching the previous explicit `role="status"`. Visual output
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #86 flipped `UserManager::createUser()`'s default status from
`Approved` to `Pending` (least-privilege). Three tests in PR #91
were written assuming the old default and relied on actors being
"silently Approved" - now that the default is `Pending`:

- `testApproveActionFlipsStatusToApproved` saw the admin show up in
  the `?status=pending` list and the form selector matched the
  admin's approve form instead of the target's.
- `testBlockActionRejectsInvalidCsrfToken` asserted the target
  stayed `Approved` after a CSRF rejection, but with the new default
  the target never started `Approved` in the first place.
- `testFindVisibleToFiltersByStatus` expected only one Pending user
  in the result, but the admin (no explicit status) was also Pending.

Fix: pass `status: UserStatus::Approved` explicitly when the test
needs an actor or a starting-state-Approved target. Inline comments
note why each one matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants