Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Admin user-management surface at `/admin/users` per ADR 006. Lists
users scoped by role — `ROLE_ADMIN` sees every user, a
`ROLE_DOMAIN_MANAGER` sees only users whose email domain matches
their own. Optional `?status=pending|approved|blocked` filter for
the approval queue (`/admin/users/pending` redirects to
`?status=pending`). Per-row Approve / Block buttons are gated by
the `ManageUserVoter` from #84 (same-domain check) and the new
`App\Security\UserApproval` service flips the status. The list
view uses a new repository finder
`UserRepository::findVisibleTo()`, scoped against the actor's
role + email domain via the `EmailDomain` helper. CSRF-protected;
`back` parameter on the action forms only honours
`/admin/users…` URLs
([#64](https://github.com/itk-dev/ai-lib/issues/64),
[#85](https://github.com/itk-dev/ai-lib/issues/85)).
- `ROLE_DOMAIN_MANAGER` + `ROLE_ADMIN` role identifiers
(`App\Security\Roles`), `role_hierarchy` wiring in `security.yaml`
so `ROLE_ADMIN` implies `ROLE_DOMAIN_MANAGER`, and a
Expand All @@ -19,6 +34,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
queue (#64) and the scoped user-management list view (#85) per
ADR 006
([#84](https://github.com/itk-dev/ai-lib/issues/84)).
- `User.name` (display name) and `User.status` (lifecycle enum:
`pending | approved | blocked`) per ADR 006, plus the
`App\Enum\UserStatus` PHP enum. `UserManager::createUser()` now
requires `name` and accepts an optional `status` (default
`Approved` for the console / fixture path; the registration flow
in #62 will pass `Pending`). The `app:user:create` console command
takes a third `name` argument; fixtures seed Alice + Bob with
display names. Schema is added via a single migration that
backfills any existing rows with `name = ''` and
`status = 'approved'`
([#45](https://github.com/itk-dev/ai-lib/issues/45),
[#83](https://github.com/itk-dev/ai-lib/issues/83)).
- Initial Symfony 8 application scaffold on the ITK Dev Docker
`symfony-8` template (phpfpm 8.4, nginx, MariaDB, Mailpit, Traefik),
including dev dependencies for coding standards (`php-cs-fixer`,
Expand Down
1 change: 1 addition & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ security:
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/logout, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_DOMAIN_MANAGER }

when@test:
security:
Expand Down
41 changes: 41 additions & 0 deletions migrations/Version20260619080000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;

/**
* Add `name` and `status` columns to `user`.
*
* `name` is the display name; `status` is the
* `UserStatus` enum (`pending | approved | blocked`).
*
* Backfill any existing rows in `up()` so the new not-null constraints
* hold in environments that already have user data (default `name = ''`,
* default `status = 'approved'` — assume historic rows were trusted).
*/
final class Version20260619080000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add name and status columns to user.';
}

public function up(Schema $schema): void
{
$user = $schema->getTable('user');
$user->addColumn('name', Types::STRING, ['length' => 255, 'notnull' => true, 'default' => '']);
$user->addColumn('status', Types::STRING, ['length' => 32, 'notnull' => true, 'default' => 'approved']);
}

public function down(Schema $schema): void
{
$user = $schema->getTable('user');
$user->dropColumn('status');
$user->dropColumn('name');
}
}
4 changes: 3 additions & 1 deletion src/Command/UserCreateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'The user\'s e-mail address (must be unique).')
->addArgument('name', InputArgument::REQUIRED, 'The user\'s display name.')
->addArgument('password', InputArgument::REQUIRED, 'The user\'s password in clear-text — will be hashed.');
}

Expand All @@ -53,10 +54,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = (string) $input->getArgument('email');
$name = (string) $input->getArgument('name');
$password = (string) $input->getArgument('password');

try {
$user = $this->userManager->createUser($email, $password);
$user = $this->userManager->createUser($email, $name, $password);
} catch (\DomainException|\InvalidArgumentException $e) {
$io->error($e->getMessage());

Expand Down
100 changes: 100 additions & 0 deletions src/Controller/Admin/UserController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\User;
use App\Enum\UserStatus;
use App\Repository\UserRepository;
use App\Security\Roles;
use App\Security\UserApproval;
use App\Security\Voter\ManageUserVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted(Roles::DOMAIN_MANAGER)]
final class UserController extends AbstractController
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly UserApproval $userApproval,
) {
}

#[Route(path: '/admin/users', name: 'app_admin_users', methods: ['GET'])]
public function list(Request $request): Response
{
$actor = $this->currentUser();
$statusFilter = $this->resolveStatusFilter((string) $request->query->get('status', ''));

return $this->render('admin/user/list.html.twig', [
'users' => $this->userRepository->findVisibleTo($actor, $statusFilter),
'status_filter' => $statusFilter,
]);
}

#[Route(path: '/admin/users/pending', name: 'app_admin_users_pending', methods: ['GET'])]
public function pending(): Response
{
return $this->redirectToRoute('app_admin_users', ['status' => UserStatus::Pending->value]);
}

#[Route(path: '/admin/users/{id}/approve', name: 'app_admin_user_approve', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted(ManageUserVoter::APPROVE, subject: 'user')]
public function approve(User $user, Request $request): Response
{
if (!$this->isCsrfTokenValid('admin-user-action', (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}

$this->userApproval->approve($user);
$this->addFlash('success', 'admin.users.flash.approved');

return $this->redirectToBackUrl($request);
}

#[Route(path: '/admin/users/{id}/block', name: 'app_admin_user_block', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted(ManageUserVoter::BLOCK, subject: 'user')]
public function block(User $user, Request $request): Response
{
if (!$this->isCsrfTokenValid('admin-user-action', (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}

$this->userApproval->block($user);
$this->addFlash('success', 'admin.users.flash.blocked');

return $this->redirectToBackUrl($request);
}

private function currentUser(): User
{
$user = $this->getUser();
\assert($user instanceof User);

return $user;
}

private function resolveStatusFilter(string $raw): ?UserStatus
{
if ('' === $raw) {
return null;
}

return UserStatus::tryFrom($raw);
}

private function redirectToBackUrl(Request $request): Response
{
$back = (string) $request->request->get('back', '');
if (str_starts_with($back, '/admin/users')) {
return $this->redirect($back);
}

return $this->redirectToRoute('app_admin_users');
}
}
4 changes: 2 additions & 2 deletions src/DataFixtures/UserFixtures.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function __construct(private readonly UserManager $userManager)
*/
public function load(ObjectManager $manager): void
{
$this->userManager->createUser('alice@example.test', 'password');
$this->userManager->createUser('bob@example.test', 'password');
$this->userManager->createUser('alice@example.test', 'Alice', 'password');
$this->userManager->createUser('bob@example.test', 'Bob', 'password');
}
}
32 changes: 32 additions & 0 deletions src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace App\Entity;

use App\Enum\UserStatus;
use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
Expand Down Expand Up @@ -32,6 +34,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column]
private ?string $password = null;

#[ORM\Column(length: 255)]
private string $name = '';

#[ORM\Column(type: Types::STRING, length: 32, enumType: UserStatus::class)]
private UserStatus $status = UserStatus::Pending;

public function getId(): ?int
{
return $this->id;
Expand Down Expand Up @@ -96,6 +104,30 @@ public function setPassword(string $password): static
return $this;
}

public function getName(): string
{
return $this->name;
}

public function setName(string $name): static
{
$this->name = $name;

return $this;
}

public function getStatus(): UserStatus
{
return $this->status;
}

public function setStatus(UserStatus $status): static
{
$this->status = $status;

return $this;
}

/**
* Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3.
*/
Expand Down
19 changes: 19 additions & 0 deletions src/Enum/UserStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Enum;

/**
* Identity-lifecycle state for a {@see \App\Entity\User}.
*
* Decided in ADR 006. The single source of truth for "may this credential
* sign in?". Authorisation (roles, voters) stays orthogonal — those answer
* "what may a signed-in user do", not "may this person sign in at all".
*/
enum UserStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Blocked = 'blocked';
}
51 changes: 51 additions & 0 deletions src/Repository/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
namespace App\Repository;

use App\Entity\User;
use App\Enum\UserStatus;
use App\Security\EmailDomain;
use App\Security\Roles;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
Expand Down Expand Up @@ -32,4 +35,52 @@ public function upgradePassword(PasswordAuthenticatedUserInterface $user, string
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}

/**
* Find users visible to the acting user, optionally filtered by status.
*
* Decision flow mirrors {@see \App\Security\Voter\ManageUserVoter}:
*
* 1. Site admins (`ROLE_ADMIN`) see every user.
* 2. Domain managers (`ROLE_DOMAIN_MANAGER` without admin) see only
* users whose email domain matches their own.
* 3. Everyone else gets an empty result — the caller must still
* gate the route via `IsGranted` first, this is a belt-and-
* braces filter for query-level scoping.
*
* @param User $actor the acting user (whose role + email determine the scope)
* @param UserStatus|null $statusFilter optional status filter for #64's approval-queue view
*
* @return list<User> users sorted by id ascending
*/
public function findVisibleTo(User $actor, ?UserStatus $statusFilter = null): array
{
$qb = $this->createQueryBuilder('u')
->orderBy('u.id', 'ASC');

$roles = $actor->getRoles();

if (\in_array(Roles::ADMIN, $roles, true)) {
// Admin sees everyone — no domain filter.
} elseif (\in_array(Roles::DOMAIN_MANAGER, $roles, true)) {
$domain = EmailDomain::of($actor);
if (null === $domain) {
return [];
}
$qb->andWhere('LOWER(u.email) LIKE :domainSuffix')
->setParameter('domainSuffix', '%@'.$domain);
} else {
return [];
}

if (null !== $statusFilter) {
$qb->andWhere('u.status = :status')
->setParameter('status', $statusFilter->value);
}

/** @var list<User> $result */
$result = $qb->getQuery()->getResult();

return $result;
}
}
Loading
Loading