feat(admin): scoped user-management with approval queue (#64, #85)#91
Open
martinydeAI wants to merge 7 commits into
Open
feat(admin): scoped user-management with approval queue (#64, #85)#91martinydeAI wants to merge 7 commits into
martinydeAI wants to merge 7 commits into
Conversation
# Conflicts: # CHANGELOG.md
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>
Removed references to ADR 006 and added clarity to column descriptions.
4 tasks
…tk-dev/ai-lib into feature/issue-85-admin-user-management
martinyde
previously approved these changes
Jun 22, 2026
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>
This was referenced Jun 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Description
Admin user-management surface:
GET /admin/users— list view scoped by role. Admins see everyuser, domain managers see only same-domain users (via the
EmailDomainhelper). Optional?status=pending|approved|blockedfilter powers the approval queue (feat: approval queue for domain managers #64).
GET /admin/users/pending— sugar route, 302 to/admin/users?status=pendingso feat: approval queue for domain managers #64's URL keeps working.POST /admin/users/{id}/approveand.../block— flip the user'sstatus. 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-protectedwith the
admin-user-actionintent token.App\Security\UserApproval— single home for thePending -> Approved/Approved -> Blockedtransitions.App\Repository\UserRepository::findVisibleTo()— scoped finderwith optional status filter. Domain managers without an email
defensively get an empty result (defence in depth — the firewall
rather than running a query against an unresolved domain).
^/adminaccess_controlrule + per-actionIsGranted—belt-and-braces.
Form/Button,Form/Label,Form/TextInput,Eyebrow, and base layout — no new component families.backparameter 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
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 theconsole for now — separate UX decision, not tracked as its own issue
yet.
The voter's
IsGrantedruns before the controller body, so across-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 theplan — its concrete payload is now the role set in #84 + the scoped
admin access here.
Details - AI specificities
docs/adr/006-user-approval-and-account-state.md(Draft; lands via docs: add ADR 004 — user registration, approval, and account state #61). Specifically the "Approval queue" and
"Domain manager — a role, not a flag" sections.
develop.do-not-mergelabel.and feat(security): ROLE_DOMAIN_MANAGER, role hierarchy, domain-scoped voter (#84) #87.
UserRepositoryTestgets the threenew methods (
testFindVisibleToReturnsEveryUserForAdmin,testFindVisibleToScopesByDomainForDomainManager,testFindVisibleToFiltersByStatus) the user approved earlier. Thescoping test absorbs the "non-manager empty" and
"domain-manager-without-email empty" branches as sub-assertions
rather than adding a 4th method, to stay within the approved
boundary.
grows.
ROLE_DOMAIN_MANAGER— separate concern.admin-user-action. Same token works for bothapprove + block actions; the route + voter combination is what
distinguishes the actual operation.