feat: Attribution de rôles multiples par utilisateur
Les utilisateurs Classeo étaient limités à un seul rôle, alors que dans la réalité scolaire un directeur peut aussi être enseignant, ou un parent peut avoir un rôle vie scolaire. Cette limitation obligeait à créer des comptes distincts par fonction. Le modèle User supporte désormais plusieurs rôles simultanés avec basculement via le header. L'admin peut attribuer/retirer des rôles depuis l'interface de gestion, avec des garde-fous : pas d'auto- destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut attribuer SUPER_ADMIN), vérification du statut actif pour le switch de rôle, et TTL explicite sur le cache de rôle actif.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignRole;
|
||||
|
||||
final readonly class AssignRoleCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $role,
|
||||
public string $tenantId = '',
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignRole;
|
||||
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class AssignRoleHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(AssignRoleCommand $command): User
|
||||
{
|
||||
$userId = UserId::fromString($command->userId);
|
||||
$user = $this->userRepository->get($userId);
|
||||
|
||||
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
|
||||
throw UserNotFoundException::withId($userId);
|
||||
}
|
||||
|
||||
$role = Role::tryFrom($command->role);
|
||||
if ($role === null) {
|
||||
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
|
||||
}
|
||||
|
||||
$user->attribuerRole($role, $this->clock->now());
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\InviteUser;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function is_string;
|
||||
|
||||
final readonly class InviteUserCommand
|
||||
{
|
||||
/** @var string[] */
|
||||
public array $roles;
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
@@ -14,6 +24,16 @@ final readonly class InviteUserCommand
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $dateNaissance = null,
|
||||
array $roles = [],
|
||||
) {
|
||||
$resolved = $roles !== [] ? $roles : [$role];
|
||||
|
||||
foreach ($resolved as $r) {
|
||||
if (!is_string($r)) {
|
||||
throw new InvalidArgumentException('Chaque rôle doit être une chaîne de caractères.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->roles = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function array_slice;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
@@ -26,16 +30,24 @@ final readonly class InviteUserHandler
|
||||
|
||||
/**
|
||||
* @throws EmailDejaUtiliseeException if email is already used in this tenant
|
||||
* @throws InvalidArgumentException if the role is invalid
|
||||
* @throws InvalidArgumentException if a role is invalid
|
||||
*/
|
||||
public function __invoke(InviteUserCommand $command): User
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$email = new Email($command->email);
|
||||
|
||||
$role = Role::tryFrom($command->role);
|
||||
if ($role === null) {
|
||||
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
|
||||
$roles = array_map(static function (string $r): Role {
|
||||
$role = Role::tryFrom($r);
|
||||
if ($role === null) {
|
||||
throw new InvalidArgumentException("Rôle invalide : \"{$r}\".");
|
||||
}
|
||||
|
||||
return $role;
|
||||
}, $command->roles);
|
||||
|
||||
if ($roles === []) {
|
||||
throw new InvalidArgumentException('Au moins un rôle est requis.');
|
||||
}
|
||||
|
||||
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
|
||||
@@ -43,19 +55,25 @@ final readonly class InviteUserHandler
|
||||
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
$user = User::inviter(
|
||||
email: $email,
|
||||
role: $role,
|
||||
role: $roles[0],
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
invitedAt: $this->clock->now(),
|
||||
invitedAt: $now,
|
||||
dateNaissance: $command->dateNaissance !== null
|
||||
? new DateTimeImmutable($command->dateNaissance)
|
||||
: null,
|
||||
);
|
||||
|
||||
foreach (array_slice($roles, 1) as $additionalRole) {
|
||||
$user->attribuerRole($additionalRole, $now);
|
||||
}
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\RemoveRole;
|
||||
|
||||
final readonly class RemoveRoleCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $role,
|
||||
public string $tenantId = '',
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\RemoveRole;
|
||||
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class RemoveRoleHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(RemoveRoleCommand $command): User
|
||||
{
|
||||
$userId = UserId::fromString($command->userId);
|
||||
$user = $this->userRepository->get($userId);
|
||||
|
||||
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
|
||||
throw UserNotFoundException::withId($userId);
|
||||
}
|
||||
|
||||
$role = Role::tryFrom($command->role);
|
||||
if ($role === null) {
|
||||
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
|
||||
}
|
||||
|
||||
$user->retirerRole($role, $this->clock->now());
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateUserRoles;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function is_string;
|
||||
|
||||
final readonly class UpdateUserRolesCommand
|
||||
{
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public array $roles,
|
||||
public string $tenantId = '',
|
||||
) {
|
||||
foreach ($roles as $r) {
|
||||
if (!is_string($r)) {
|
||||
throw new InvalidArgumentException('Chaque rôle doit être une chaîne de caractères.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateUserRoles;
|
||||
|
||||
use App\Administration\Application\Port\ActiveRoleStore;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
use function in_array;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateUserRolesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private Clock $clock,
|
||||
private ActiveRoleStore $activeRoleStore,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdateUserRolesCommand $command): User
|
||||
{
|
||||
$userId = UserId::fromString($command->userId);
|
||||
$user = $this->userRepository->get($userId);
|
||||
|
||||
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
|
||||
throw UserNotFoundException::withId($userId);
|
||||
}
|
||||
|
||||
if ($command->roles === []) {
|
||||
throw new InvalidArgumentException('Au moins un rôle est requis.');
|
||||
}
|
||||
|
||||
$targetRoles = array_map(static function (string $r): Role {
|
||||
$role = Role::tryFrom($r);
|
||||
if ($role === null) {
|
||||
throw new InvalidArgumentException("Rôle invalide : \"{$r}\".");
|
||||
}
|
||||
|
||||
return $role;
|
||||
}, $command->roles);
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Add new roles first to avoid "last role" exception
|
||||
foreach ($targetRoles as $targetRole) {
|
||||
if (!$user->aLeRole($targetRole)) {
|
||||
$user->attribuerRole($targetRole, $now);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect roles to remove, then remove them (avoid mutating during iteration)
|
||||
/** @var list<Role> $rolesToRemove */
|
||||
$rolesToRemove = array_values(array_filter(
|
||||
$user->roles,
|
||||
static fn (Role $currentRole) => !in_array($currentRole, $targetRoles, true),
|
||||
));
|
||||
|
||||
foreach ($rolesToRemove as $roleToRemove) {
|
||||
$user->retirerRole($roleToRemove, $now);
|
||||
}
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Clear cached active role to avoid stale reference to a removed role
|
||||
$this->activeRoleStore->clear($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
|
||||
/**
|
||||
* Port interface for storing the user's currently active role.
|
||||
*
|
||||
* When a user has multiple roles (FR5), they can switch between them.
|
||||
* This store persists the selected role across requests.
|
||||
*/
|
||||
interface ActiveRoleStore
|
||||
{
|
||||
/**
|
||||
* Stores the active role for the given user.
|
||||
*/
|
||||
public function store(User $user, Role $role): void;
|
||||
|
||||
/**
|
||||
* Returns the stored active role for the given user, or null if none.
|
||||
*/
|
||||
public function get(User $user): ?Role;
|
||||
|
||||
/**
|
||||
* Clears the stored active role for the given user (e.g. at logout).
|
||||
*/
|
||||
public function clear(User $user): void;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ final readonly class GetUsersHandler
|
||||
if ($filterRole !== null) {
|
||||
$users = array_filter(
|
||||
$users,
|
||||
static fn ($user) => $user->role === $filterRole,
|
||||
static fn ($user) => $user->aLeRole($filterRole),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetUsers;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class UserDto
|
||||
{
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $email,
|
||||
public string $role,
|
||||
public string $roleLabel,
|
||||
/** @var string[] */
|
||||
public array $roles,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public string $statut,
|
||||
@@ -34,6 +40,7 @@ final readonly class UserDto
|
||||
email: (string) $user->email,
|
||||
role: $user->role->value,
|
||||
roleLabel: $user->role->label(),
|
||||
roles: array_map(static fn (Role $r) => $r->value, $user->roles),
|
||||
firstName: $user->firstName,
|
||||
lastName: $user->lastName,
|
||||
statut: $user->statut->value,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Application\Port\ActiveRoleStore;
|
||||
use App\Administration\Domain\Exception\RoleNonAttribueException;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use DomainException;
|
||||
|
||||
final class RoleContext
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActiveRoleStore $activeRoleStore,
|
||||
) {
|
||||
}
|
||||
|
||||
public function switchTo(User $user, Role $role): void
|
||||
{
|
||||
if (!$user->peutSeConnecter()) {
|
||||
throw new DomainException('Le compte n\'est pas actif.');
|
||||
}
|
||||
|
||||
if (!$user->aLeRole($role)) {
|
||||
throw RoleNonAttribueException::pour($user->id, $role);
|
||||
}
|
||||
|
||||
$this->activeRoleStore->store($user, $role);
|
||||
}
|
||||
|
||||
public function getActiveRole(User $user): Role
|
||||
{
|
||||
$stored = $this->activeRoleStore->get($user);
|
||||
|
||||
if ($stored !== null) {
|
||||
return $stored;
|
||||
}
|
||||
|
||||
return $user->rolePrincipal();
|
||||
}
|
||||
|
||||
public function clear(User $user): void
|
||||
{
|
||||
$this->activeRoleStore->clear($user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user