diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index cb54532..fc3b0c1 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -49,7 +49,4 @@ framework: routing: # Route your messages to the transports - # Password reset events are async to prevent timing attacks (email enumeration) - # and to improve API response time - 'App\Administration\Domain\Event\PasswordResetTokenGenerated': async - 'App\Administration\Domain\Event\MotDePasseChange': async + # 'App\Message\YourMessage': async diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 88c7faf..a6117eb 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -92,6 +92,11 @@ services: arguments: $appUrl: '%app.url%' + App\Shared\Infrastructure\Tenant\TenantUrlBuilder: + arguments: + $appUrl: '%app.url%' + $baseDomain: '%tenant.base_domain%' + # Audit Logger Service (writes to append-only audit_log table) App\Shared\Application\Port\AuditLogger: alias: App\Shared\Infrastructure\Audit\AuditLogger diff --git a/backend/src/Administration/Application/Command/BlockUser/BlockUserCommand.php b/backend/src/Administration/Application/Command/BlockUser/BlockUserCommand.php new file mode 100644 index 0000000..48cb5bc --- /dev/null +++ b/backend/src/Administration/Application/Command/BlockUser/BlockUserCommand.php @@ -0,0 +1,15 @@ +userId); + $user = $this->userRepository->get($userId); + + if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) { + throw UserNotFoundException::withId($userId); + } + + $user->bloquer($command->reason, $this->clock->now()); + + $this->userRepository->save($user); + + return $user; + } +} diff --git a/backend/src/Administration/Application/Command/InviteUser/InviteUserCommand.php b/backend/src/Administration/Application/Command/InviteUser/InviteUserCommand.php new file mode 100644 index 0000000..a213673 --- /dev/null +++ b/backend/src/Administration/Application/Command/InviteUser/InviteUserCommand.php @@ -0,0 +1,19 @@ +tenantId); + $email = new Email($command->email); + + $role = Role::tryFrom($command->role); + if ($role === null) { + throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\"."); + } + + $existingUser = $this->userRepository->findByEmail($email, $tenantId); + if ($existingUser !== null) { + throw EmailDejaUtiliseeException::dansTenant($email, $tenantId); + } + + $user = User::inviter( + email: $email, + role: $role, + tenantId: $tenantId, + schoolName: $command->schoolName, + firstName: $command->firstName, + lastName: $command->lastName, + invitedAt: $this->clock->now(), + dateNaissance: $command->dateNaissance !== null + ? new DateTimeImmutable($command->dateNaissance) + : null, + ); + + $this->userRepository->save($user); + + return $user; + } +} diff --git a/backend/src/Administration/Application/Command/ResendInvitation/ResendInvitationCommand.php b/backend/src/Administration/Application/Command/ResendInvitation/ResendInvitationCommand.php new file mode 100644 index 0000000..58e2e49 --- /dev/null +++ b/backend/src/Administration/Application/Command/ResendInvitation/ResendInvitationCommand.php @@ -0,0 +1,14 @@ +userId); + $user = $this->userRepository->get($userId); + + if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) { + throw UserNotFoundException::withId($userId); + } + + $user->renvoyerInvitation($this->clock->now()); + + $this->userRepository->save($user); + + return $user; + } +} diff --git a/backend/src/Administration/Application/Command/UnblockUser/UnblockUserCommand.php b/backend/src/Administration/Application/Command/UnblockUser/UnblockUserCommand.php new file mode 100644 index 0000000..c6fdbef --- /dev/null +++ b/backend/src/Administration/Application/Command/UnblockUser/UnblockUserCommand.php @@ -0,0 +1,14 @@ +userId); + $user = $this->userRepository->get($userId); + + if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) { + throw UserNotFoundException::withId($userId); + } + + $user->debloquer($this->clock->now()); + + $this->userRepository->save($user); + + return $user; + } +} diff --git a/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php b/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php new file mode 100644 index 0000000..6d48268 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php @@ -0,0 +1,58 @@ +userRepository->findAllByTenant( + TenantId::fromString($query->tenantId), + ); + + // Apply filters + if ($query->role !== null) { + $filterRole = Role::tryFrom($query->role); + if ($filterRole !== null) { + $users = array_filter( + $users, + static fn ($user) => $user->role === $filterRole, + ); + } + } + + if ($query->statut !== null) { + $filterStatut = StatutCompte::tryFrom($query->statut); + if ($filterStatut !== null) { + $users = array_filter( + $users, + static fn ($user) => $user->statut === $filterStatut, + ); + } + } + + return array_values(array_map( + fn ($user) => UserDto::fromDomain($user, $this->clock), + $users, + )); + } +} diff --git a/backend/src/Administration/Application/Query/GetUsers/GetUsersQuery.php b/backend/src/Administration/Application/Query/GetUsers/GetUsersQuery.php new file mode 100644 index 0000000..c99bc01 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetUsers/GetUsersQuery.php @@ -0,0 +1,15 @@ +id, + email: (string) $user->email, + role: $user->role->value, + roleLabel: $user->role->label(), + firstName: $user->firstName, + lastName: $user->lastName, + statut: $user->statut->value, + createdAt: $user->createdAt, + invitedAt: $user->invitedAt, + activatedAt: $user->activatedAt, + blockedAt: $user->blockedAt, + blockedReason: $user->blockedReason, + invitationExpiree: $user->estInvitationExpiree($clock->now()), + ); + } +} diff --git a/backend/src/Administration/Domain/Event/InvitationRenvoyee.php b/backend/src/Administration/Domain/Event/InvitationRenvoyee.php new file mode 100644 index 0000000..86ab46a --- /dev/null +++ b/backend/src/Administration/Domain/Event/InvitationRenvoyee.php @@ -0,0 +1,35 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/UtilisateurBloque.php b/backend/src/Administration/Domain/Event/UtilisateurBloque.php new file mode 100644 index 0000000..2d49abe --- /dev/null +++ b/backend/src/Administration/Domain/Event/UtilisateurBloque.php @@ -0,0 +1,36 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/UtilisateurDebloque.php b/backend/src/Administration/Domain/Event/UtilisateurDebloque.php new file mode 100644 index 0000000..ce52ce6 --- /dev/null +++ b/backend/src/Administration/Domain/Event/UtilisateurDebloque.php @@ -0,0 +1,35 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/UtilisateurInvite.php b/backend/src/Administration/Domain/Event/UtilisateurInvite.php new file mode 100644 index 0000000..a9c1996 --- /dev/null +++ b/backend/src/Administration/Domain/Event/UtilisateurInvite.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/EmailDejaUtiliseeException.php b/backend/src/Administration/Domain/Exception/EmailDejaUtiliseeException.php new file mode 100644 index 0000000..3ba7e8f --- /dev/null +++ b/backend/src/Administration/Domain/Exception/EmailDejaUtiliseeException.php @@ -0,0 +1,23 @@ +value, + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/UtilisateurNonBlocableException.php b/backend/src/Administration/Domain/Exception/UtilisateurNonBlocableException.php new file mode 100644 index 0000000..2e8fb38 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/UtilisateurNonBlocableException.php @@ -0,0 +1,23 @@ +value, + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/UtilisateurNonDeblocableException.php b/backend/src/Administration/Domain/Exception/UtilisateurNonDeblocableException.php new file mode 100644 index 0000000..fd2332d --- /dev/null +++ b/backend/src/Administration/Domain/Exception/UtilisateurNonDeblocableException.php @@ -0,0 +1,23 @@ +value, + )); + } +} diff --git a/backend/src/Administration/Domain/Model/User/User.php b/backend/src/Administration/Domain/Model/User/User.php index b5dd8a8..da59bd1 100644 --- a/backend/src/Administration/Domain/Model/User/User.php +++ b/backend/src/Administration/Domain/Model/User/User.php @@ -6,8 +6,15 @@ namespace App\Administration\Domain\Model\User; use App\Administration\Domain\Event\CompteActive; use App\Administration\Domain\Event\CompteCreated; +use App\Administration\Domain\Event\InvitationRenvoyee; use App\Administration\Domain\Event\MotDePasseChange; +use App\Administration\Domain\Event\UtilisateurBloque; +use App\Administration\Domain\Event\UtilisateurDebloque; +use App\Administration\Domain\Event\UtilisateurInvite; use App\Administration\Domain\Exception\CompteNonActivableException; +use App\Administration\Domain\Exception\UtilisateurDejaInviteException; +use App\Administration\Domain\Exception\UtilisateurNonBlocableException; +use App\Administration\Domain\Exception\UtilisateurNonDeblocableException; use App\Administration\Domain\Model\ConsentementParental\ConsentementParental; use App\Administration\Domain\Policy\ConsentementParentalPolicy; use App\Shared\Domain\AggregateRoot; @@ -26,6 +33,9 @@ final class User extends AggregateRoot public private(set) ?string $hashedPassword = null; public private(set) ?DateTimeImmutable $activatedAt = null; public private(set) ?ConsentementParental $consentementParental = null; + public private(set) ?DateTimeImmutable $invitedAt = null; + public private(set) ?DateTimeImmutable $blockedAt = null; + public private(set) ?string $blockedReason = null; private function __construct( public private(set) UserId $id, @@ -36,6 +46,8 @@ final class User extends AggregateRoot public private(set) StatutCompte $statut, public private(set) ?DateTimeImmutable $dateNaissance, public private(set) DateTimeImmutable $createdAt, + public private(set) string $firstName = '', + public private(set) string $lastName = '', ) { } @@ -136,6 +148,134 @@ final class User extends AggregateRoot return $this->statut->peutSeConnecter(); } + /** + * Creates a new user via admin invitation. + * + * Unlike creer() which is for self-registration, inviter() is used + * when an admin creates a user account from the management interface. + */ + public static function inviter( + Email $email, + Role $role, + TenantId $tenantId, + string $schoolName, + string $firstName, + string $lastName, + DateTimeImmutable $invitedAt, + ?DateTimeImmutable $dateNaissance = null, + ): self { + $user = new self( + id: UserId::generate(), + email: $email, + role: $role, + tenantId: $tenantId, + schoolName: $schoolName, + statut: StatutCompte::EN_ATTENTE, + dateNaissance: $dateNaissance, + createdAt: $invitedAt, + firstName: $firstName, + lastName: $lastName, + ); + + $user->invitedAt = $invitedAt; + + $user->recordEvent(new UtilisateurInvite( + userId: $user->id, + email: (string) $user->email, + role: $user->role->value, + firstName: $firstName, + lastName: $lastName, + tenantId: $user->tenantId, + occurredOn: $invitedAt, + )); + + return $user; + } + + /** + * Resends the invitation for a user still awaiting activation. + * + * @throws UtilisateurDejaInviteException if user is no longer in a pending state + */ + public function renvoyerInvitation(DateTimeImmutable $at): void + { + if ($this->statut !== StatutCompte::EN_ATTENTE && $this->statut !== StatutCompte::CONSENTEMENT_REQUIS) { + throw UtilisateurDejaInviteException::carStatutIncompatible($this->id, $this->statut); + } + + $this->invitedAt = $at; + + $this->recordEvent(new InvitationRenvoyee( + userId: $this->id, + email: (string) $this->email, + tenantId: $this->tenantId, + occurredOn: $at, + )); + } + + /** + * Blocks a user account. + * + * @throws UtilisateurNonBlocableException if user is already suspended or archived + */ + public function bloquer(string $reason, DateTimeImmutable $at): void + { + if ($this->statut !== StatutCompte::ACTIF) { + throw UtilisateurNonBlocableException::carStatutIncompatible($this->id, $this->statut); + } + + $this->statut = StatutCompte::SUSPENDU; + $this->blockedAt = $at; + $this->blockedReason = $reason; + + $this->recordEvent(new UtilisateurBloque( + userId: $this->id, + email: (string) $this->email, + reason: $reason, + tenantId: $this->tenantId, + occurredOn: $at, + )); + } + + /** + * Unblocks a suspended user account, restoring it to active status. + * + * @throws UtilisateurNonDeblocableException if user is not suspended + */ + public function debloquer(DateTimeImmutable $at): void + { + if ($this->statut !== StatutCompte::SUSPENDU) { + throw UtilisateurNonDeblocableException::carStatutIncompatible($this->id, $this->statut); + } + + $this->statut = StatutCompte::ACTIF; + $this->blockedAt = null; + $this->blockedReason = null; + + $this->recordEvent(new UtilisateurDebloque( + userId: $this->id, + email: (string) $this->email, + tenantId: $this->tenantId, + occurredOn: $at, + )); + } + + /** + * Checks if the invitation has expired (> 7 days since last invitation). + */ + public function estInvitationExpiree(DateTimeImmutable $at): bool + { + if ($this->statut !== StatutCompte::EN_ATTENTE && $this->statut !== StatutCompte::CONSENTEMENT_REQUIS) { + return false; + } + + if ($this->invitedAt === null) { + return false; + } + + return $at > $this->invitedAt->modify('+7 days'); + } + /** * Changes the user's password. * @@ -170,6 +310,11 @@ final class User extends AggregateRoot ?string $hashedPassword, ?DateTimeImmutable $activatedAt, ?ConsentementParental $consentementParental, + string $firstName = '', + string $lastName = '', + ?DateTimeImmutable $invitedAt = null, + ?DateTimeImmutable $blockedAt = null, + ?string $blockedReason = null, ): self { $user = new self( id: $id, @@ -180,11 +325,16 @@ final class User extends AggregateRoot statut: $statut, dateNaissance: $dateNaissance, createdAt: $createdAt, + firstName: $firstName, + lastName: $lastName, ); $user->hashedPassword = $hashedPassword; $user->activatedAt = $activatedAt; $user->consentementParental = $consentementParental; + $user->invitedAt = $invitedAt; + $user->blockedAt = $blockedAt; + $user->blockedReason = $blockedReason; return $user; } diff --git a/backend/src/Administration/Domain/Repository/UserRepository.php b/backend/src/Administration/Domain/Repository/UserRepository.php index 9bc594b..f23567d 100644 --- a/backend/src/Administration/Domain/Repository/UserRepository.php +++ b/backend/src/Administration/Domain/Repository/UserRepository.php @@ -23,4 +23,11 @@ interface UserRepository * Returns null if user doesn't exist in that tenant. */ public function findByEmail(Email $email, TenantId $tenantId): ?User; + + /** + * Returns all users for a given tenant. + * + * @return User[] + */ + public function findAllByTenant(TenantId $tenantId): array; } diff --git a/backend/src/Administration/Infrastructure/Api/Processor/BlockUserProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/BlockUserProcessor.php new file mode 100644 index 0000000..c74a849 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/BlockUserProcessor.php @@ -0,0 +1,88 @@ + + */ +final readonly class BlockUserProcessor implements ProcessorInterface +{ + public function __construct( + private BlockUserHandler $handler, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private TenantContext $tenantContext, + private Security $security, + private Clock $clock, + ) { + } + + /** + * @param UserResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UserResource + { + if (!$this->authorizationChecker->isGranted(UserVoter::BLOCK)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à bloquer un utilisateur.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $userId */ + $userId = $uriVariables['id'] ?? ''; + + $currentUser = $this->security->getUser(); + if ($currentUser instanceof SecurityUser && $currentUser->userId() === $userId) { + throw new BadRequestHttpException('Vous ne pouvez pas bloquer votre propre compte.'); + } + + $reason = trim($data->reason ?? ''); + if ($reason === '') { + throw new BadRequestHttpException('La raison du blocage est obligatoire.'); + } + + try { + $command = new BlockUserCommand( + userId: $userId, + reason: $reason, + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + ); + $user = ($this->handler)($command); + + foreach ($user->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return UserResource::fromDomain($user, $this->clock->now()); + } catch (UserNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (UtilisateurNonBlocableException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/InviteUserProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/InviteUserProcessor.php new file mode 100644 index 0000000..8074e15 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/InviteUserProcessor.php @@ -0,0 +1,80 @@ + + */ +final readonly class InviteUserProcessor implements ProcessorInterface +{ + public function __construct( + private InviteUserHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private Clock $clock, + ) { + } + + /** + * @param UserResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UserResource + { + if (!$this->authorizationChecker->isGranted(UserVoter::CREATE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer un utilisateur.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $tenantConfig = $this->tenantContext->getCurrentTenantConfig(); + + try { + $command = new InviteUserCommand( + tenantId: $tenantId, + schoolName: $tenantConfig->subdomain, + email: $data->email ?? '', + role: $data->role ?? '', + firstName: $data->firstName ?? '', + lastName: $data->lastName ?? '', + ); + + $user = ($this->handler)($command); + + foreach ($user->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return UserResource::fromDomain($user, $this->clock->now()); + } catch (EmailInvalideException|InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (EmailDejaUtiliseeException $e) { + throw new ConflictHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/ResendInvitationProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/ResendInvitationProcessor.php new file mode 100644 index 0000000..427b09a --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/ResendInvitationProcessor.php @@ -0,0 +1,74 @@ + + */ +final readonly class ResendInvitationProcessor implements ProcessorInterface +{ + public function __construct( + private ResendInvitationHandler $handler, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private TenantContext $tenantContext, + private Clock $clock, + ) { + } + + /** + * @param UserResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UserResource + { + if (!$this->authorizationChecker->isGranted(UserVoter::RESEND_INVITATION)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à renvoyer une invitation.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $userId */ + $userId = $uriVariables['id'] ?? ''; + + try { + $command = new ResendInvitationCommand( + userId: $userId, + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + ); + $user = ($this->handler)($command); + + foreach ($user->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return UserResource::fromDomain($user, $this->clock->now()); + } catch (UserNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (UtilisateurDejaInviteException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UnblockUserProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UnblockUserProcessor.php new file mode 100644 index 0000000..ffb2e82 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/UnblockUserProcessor.php @@ -0,0 +1,74 @@ + + */ +final readonly class UnblockUserProcessor implements ProcessorInterface +{ + public function __construct( + private UnblockUserHandler $handler, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private TenantContext $tenantContext, + private Clock $clock, + ) { + } + + /** + * @param UserResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UserResource + { + if (!$this->authorizationChecker->isGranted(UserVoter::UNBLOCK)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à débloquer un utilisateur.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $userId */ + $userId = $uriVariables['id'] ?? ''; + + try { + $command = new UnblockUserCommand( + userId: $userId, + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + ); + $user = ($this->handler)($command); + + foreach ($user->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return UserResource::fromDomain($user, $this->clock->now()); + } catch (UserNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (UtilisateurNonDeblocableException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/UserCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/UserCollectionProvider.php new file mode 100644 index 0000000..3e3f768 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/UserCollectionProvider.php @@ -0,0 +1,64 @@ + + */ +final readonly class UserCollectionProvider implements ProviderInterface +{ + public function __construct( + private GetUsersHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @return UserResource[] + */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->authorizationChecker->isGranted(UserVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les utilisateurs.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + $query = new GetUsersQuery( + tenantId: $tenantId, + role: isset($filters['role']) ? (string) $filters['role'] : null, + statut: isset($filters['statut']) ? (string) $filters['statut'] : null, + ); + + $userDtos = ($this->handler)($query); + + return array_map(UserResource::fromDto(...), $userDtos); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/UserItemProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/UserItemProvider.php new file mode 100644 index 0000000..ab7dcd6 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/UserItemProvider.php @@ -0,0 +1,63 @@ + + */ +final readonly class UserItemProvider implements ProviderInterface +{ + public function __construct( + private UserRepository $userRepository, + private AuthorizationCheckerInterface $authorizationChecker, + private TenantContext $tenantContext, + private Clock $clock, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): UserResource + { + if (!$this->authorizationChecker->isGranted(UserVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cet utilisateur.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $userId */ + $userId = $uriVariables['id'] ?? ''; + + try { + $user = $this->userRepository->get(UserId::fromString($userId)); + } catch (UserNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } + + if (!$user->tenantId->equals($this->tenantContext->getCurrentTenantId())) { + throw new NotFoundHttpException('User not found.'); + } + + return UserResource::fromDomain($user, $this->clock->now()); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/UserResource.php b/backend/src/Administration/Infrastructure/Api/Resource/UserResource.php new file mode 100644 index 0000000..5375d5b --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/UserResource.php @@ -0,0 +1,142 @@ + ['Default', 'create']], + name: 'invite_user', + ), + new Post( + uriTemplate: '/users/{id}/resend-invitation', + processor: ResendInvitationProcessor::class, + name: 'resend_invitation', + ), + new Post( + uriTemplate: '/users/{id}/block', + processor: BlockUserProcessor::class, + validationContext: ['groups' => ['Default', 'block']], + name: 'block_user', + ), + new Post( + uriTemplate: '/users/{id}/unblock', + processor: UnblockUserProcessor::class, + name: 'unblock_user', + ), + ], +)] +final class UserResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Assert\NotBlank(message: 'L\'email est requis.', groups: ['create'])] + #[Assert\Email(message: 'L\'email n\'est pas valide.')] + public ?string $email = null; + + #[Assert\NotBlank(message: 'Le rôle est requis.', groups: ['create'])] + public ?string $role = null; + + public ?string $roleLabel = null; + + #[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])] + public ?string $firstName = null; + + #[Assert\NotBlank(message: 'Le nom est requis.', groups: ['create'])] + public ?string $lastName = null; + + public ?string $statut = null; + + public ?DateTimeImmutable $createdAt = null; + + public ?DateTimeImmutable $invitedAt = null; + + public ?DateTimeImmutable $activatedAt = null; + + public ?DateTimeImmutable $blockedAt = null; + + public ?string $blockedReason = null; + + #[ApiProperty(readable: true, writable: false)] + public ?bool $invitationExpiree = null; + + #[Assert\NotBlank(message: 'La raison de blocage est requise.', groups: ['block'])] + public ?string $reason = null; + + public static function fromDomain(User $user, ?DateTimeImmutable $now = null): self + { + $resource = new self(); + $resource->id = (string) $user->id; + $resource->email = (string) $user->email; + $resource->role = $user->role->value; + $resource->roleLabel = $user->role->label(); + $resource->firstName = $user->firstName; + $resource->lastName = $user->lastName; + $resource->statut = $user->statut->value; + $resource->createdAt = $user->createdAt; + $resource->invitedAt = $user->invitedAt; + $resource->activatedAt = $user->activatedAt; + $resource->blockedAt = $user->blockedAt; + $resource->blockedReason = $user->blockedReason; + $resource->invitationExpiree = $now !== null ? $user->estInvitationExpiree($now) : false; + + return $resource; + } + + public static function fromDto(UserDto $dto): self + { + $resource = new self(); + $resource->id = $dto->id; + $resource->email = $dto->email; + $resource->role = $dto->role; + $resource->roleLabel = $dto->roleLabel; + $resource->firstName = $dto->firstName; + $resource->lastName = $dto->lastName; + $resource->statut = $dto->statut; + $resource->createdAt = $dto->createdAt; + $resource->invitedAt = $dto->invitedAt; + $resource->activatedAt = $dto->activatedAt; + $resource->blockedAt = $dto->blockedAt; + $resource->blockedReason = $dto->blockedReason; + $resource->invitationExpiree = $dto->invitationExpiree; + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php index 2e98d57..c884925 100644 --- a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php +++ b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php @@ -147,22 +147,29 @@ final class CreateTestActivationTokenCommand extends Command } $now = $this->clock->now(); + $emailVO = new Email($email); - // Create user - $dateNaissance = $isMinor - ? $now->modify('-13 years') // 13 ans = mineur - : null; + // Check if user already exists for this tenant + $user = $this->userRepository->findByEmail($emailVO, $tenantId); - $user = User::creer( - email: new Email($email), - role: $role, - tenantId: $tenantId, - schoolName: $schoolName, - dateNaissance: $dateNaissance, - createdAt: $now, - ); + if ($user !== null) { + $io->note(sprintf('User "%s" already exists, reusing existing account.', $email)); + } else { + $dateNaissance = $isMinor + ? $now->modify('-13 years') // 13 ans = mineur + : null; - $this->userRepository->save($user); + $user = User::creer( + email: $emailVO, + role: $role, + tenantId: $tenantId, + schoolName: $schoolName, + dateNaissance: $dateNaissance, + createdAt: $now, + ); + + $this->userRepository->save($user); + } // Create activation token $token = ActivationToken::generate( diff --git a/backend/src/Administration/Infrastructure/Messaging/SendInvitationEmailHandler.php b/backend/src/Administration/Infrastructure/Messaging/SendInvitationEmailHandler.php new file mode 100644 index 0000000..6dcd7a1 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Messaging/SendInvitationEmailHandler.php @@ -0,0 +1,74 @@ +userRepository->get(UserId::fromString((string) $event->userId)); + + $token = ActivationToken::generate( + userId: (string) $event->userId, + email: $event->email, + tenantId: $event->tenantId, + role: $event->role, + schoolName: $user->schoolName, + createdAt: $this->clock->now(), + ); + + $this->tokenRepository->save($token); + + $roleEnum = Role::tryFrom($event->role); + $roleLabel = $roleEnum?->label() ?? $event->role; + + $activationUrl = $this->tenantUrlBuilder->build($event->tenantId, '/activate/' . $token->tokenValue); + + $html = $this->twig->render('emails/invitation.html.twig', [ + 'firstName' => $event->firstName, + 'lastName' => $event->lastName, + 'role' => $roleLabel, + 'activationUrl' => $activationUrl, + ]); + + $email = (new Email()) + ->from($this->fromEmail) + ->to($event->email) + ->subject('Invitation à rejoindre Classeo') + ->html($html); + + $this->mailer->send($email); + } +} diff --git a/backend/src/Administration/Infrastructure/Messaging/SendResendInvitationEmailHandler.php b/backend/src/Administration/Infrastructure/Messaging/SendResendInvitationEmailHandler.php new file mode 100644 index 0000000..3a51e84 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Messaging/SendResendInvitationEmailHandler.php @@ -0,0 +1,83 @@ +userRepository->get(UserId::fromString((string) $event->userId)); + } catch (UserNotFoundException $e) { + $this->logger->warning('User no longer exists when processing resend invitation event, skipping.', [ + 'userId' => (string) $event->userId, + 'email' => $event->email, + 'exception' => $e->getMessage(), + ]); + + return; + } + + $token = ActivationToken::generate( + userId: (string) $event->userId, + email: $event->email, + tenantId: $event->tenantId, + role: $user->role->value, + schoolName: $user->schoolName, + createdAt: $this->clock->now(), + ); + + $this->tokenRepository->save($token); + + $activationUrl = $this->tenantUrlBuilder->build($event->tenantId, '/activate/' . $token->tokenValue); + + $html = $this->twig->render('emails/invitation.html.twig', [ + 'firstName' => $user->firstName, + 'lastName' => $user->lastName, + 'role' => $user->role->label(), + 'activationUrl' => $activationUrl, + ]); + + $email = (new Email()) + ->from($this->fromEmail) + ->to($event->email) + ->subject('Rappel : Invitation à rejoindre Classeo') + ->html($html); + + $this->mailer->send($email); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php index d9fa59f..6aaca11 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php @@ -14,6 +14,9 @@ use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Repository\UserRepository; use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; + +use function in_array; + use Psr\Cache\CacheItemPoolInterface; /** @@ -27,6 +30,7 @@ final readonly class CacheUserRepository implements UserRepository { private const string KEY_PREFIX = 'user:'; private const string EMAIL_INDEX_PREFIX = 'user_email:'; + private const string TENANT_INDEX_PREFIX = 'user_tenant:'; public function __construct( private CacheItemPoolInterface $usersCache, @@ -45,6 +49,18 @@ final readonly class CacheUserRepository implements UserRepository $emailItem = $this->usersCache->getItem($emailKey); $emailItem->set((string) $user->id); $this->usersCache->save($emailItem); + + // Save tenant index for listing users + $tenantKey = self::TENANT_INDEX_PREFIX . $user->tenantId; + $tenantItem = $this->usersCache->getItem($tenantKey); + /** @var string[] $userIds */ + $userIds = $tenantItem->isHit() ? $tenantItem->get() : []; + $userId = (string) $user->id; + if (!in_array($userId, $userIds, true)) { + $userIds[] = $userId; + } + $tenantItem->set($userIds); + $this->usersCache->save($tenantItem); } public function findById(UserId $id): ?User @@ -55,7 +71,7 @@ final readonly class CacheUserRepository implements UserRepository return null; } - /** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */ + /** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */ $data = $item->get(); return $this->deserialize($data); @@ -87,6 +103,29 @@ final readonly class CacheUserRepository implements UserRepository return $user; } + public function findAllByTenant(TenantId $tenantId): array + { + $tenantKey = self::TENANT_INDEX_PREFIX . $tenantId; + $tenantItem = $this->usersCache->getItem($tenantKey); + + if (!$tenantItem->isHit()) { + return []; + } + + /** @var string[] $userIds */ + $userIds = $tenantItem->get(); + $users = []; + + foreach ($userIds as $userId) { + $user = $this->findById(UserId::fromString($userId)); + if ($user !== null) { + $users[] = $user; + } + } + + return $users; + } + /** * @return array */ @@ -105,6 +144,11 @@ final readonly class CacheUserRepository implements UserRepository 'date_naissance' => $user->dateNaissance?->format('Y-m-d'), 'created_at' => $user->createdAt->format('c'), 'activated_at' => $user->activatedAt?->format('c'), + 'first_name' => $user->firstName, + 'last_name' => $user->lastName, + 'invited_at' => $user->invitedAt?->format('c'), + 'blocked_at' => $user->blockedAt?->format('c'), + 'blocked_reason' => $user->blockedReason, 'consentement_parental' => $consentement !== null ? [ 'parent_id' => $consentement->parentId, 'eleve_id' => $consentement->eleveId, @@ -126,6 +170,11 @@ final readonly class CacheUserRepository implements UserRepository * date_naissance: string|null, * created_at: string, * activated_at: string|null, + * first_name?: string, + * last_name?: string, + * invited_at?: string|null, + * blocked_at?: string|null, + * blocked_reason?: string|null, * consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null * } $data */ @@ -142,6 +191,9 @@ final readonly class CacheUserRepository implements UserRepository ); } + $invitedAt = ($data['invited_at'] ?? null) !== null ? new DateTimeImmutable($data['invited_at']) : null; + $blockedAt = ($data['blocked_at'] ?? null) !== null ? new DateTimeImmutable($data['blocked_at']) : null; + return User::reconstitute( id: UserId::fromString($data['id']), email: new Email($data['email']), @@ -154,6 +206,11 @@ final readonly class CacheUserRepository implements UserRepository hashedPassword: $data['hashed_password'], activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null, consentementParental: $consentement, + firstName: $data['first_name'] ?? '', + lastName: $data['last_name'] ?? '', + invitedAt: $invitedAt, + blockedAt: $blockedAt, + blockedReason: $data['blocked_reason'] ?? null, ); } diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php index b66fee1..0ad3fcc 100644 --- a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php @@ -45,6 +45,15 @@ final class InMemoryUserRepository implements UserRepository return $this->byTenantEmail[$this->emailKey($email, $tenantId)] ?? null; } + #[Override] + public function findAllByTenant(TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (User $user): bool => $user->tenantId->equals($tenantId), + )); + } + private function emailKey(Email $email, TenantId $tenantId): string { return $tenantId . ':' . strtolower((string) $email); diff --git a/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php b/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php index 5f49bbe..3dc1127 100644 --- a/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php +++ b/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php @@ -6,12 +6,14 @@ namespace App\Administration\Infrastructure\Security; use App\Administration\Domain\Exception\EmailInvalideException; use App\Administration\Domain\Model\User\Email; +use App\Administration\Domain\Model\User\StatutCompte; use App\Administration\Domain\Repository\UserRepository; use App\Shared\Infrastructure\Tenant\TenantId; use App\Shared\Infrastructure\Tenant\TenantNotFoundException; use App\Shared\Infrastructure\Tenant\TenantResolver; use InvalidArgumentException; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; use Symfony\Component\Security\Core\Exception\UserNotFoundException as SymfonyUserNotFoundException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; @@ -55,7 +57,15 @@ final readonly class DatabaseUserProvider implements UserProviderInterface throw new SymfonyUserNotFoundException(); } - // Do not allow login if the account is not active + // Blocked account: specific message so the user understands why they can't log in + if ($user->statut === StatutCompte::SUSPENDU) { + throw new CustomUserMessageAccountStatusException( + 'Votre compte a été suspendu. Contactez votre établissement.', + messageData: ['statut' => 'suspended'], + ); + } + + // Other non-active statuses (pending, consent, archived): generic error if (!$user->peutSeConnecter()) { throw new SymfonyUserNotFoundException(); } diff --git a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php index 30605ac..a4c6366 100644 --- a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php +++ b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php @@ -22,6 +22,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Throwable; @@ -48,6 +49,15 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { + // Suspended account: return a specific message without recording a rate-limit failure. + // This prevents blocked users from triggering CAPTCHA/delay escalation + // while informing them clearly why they cannot log in. + if ($exception instanceof CustomUserMessageAccountStatusException) { + $this->metricsCollector->recordLoginFailure('account_suspended'); + + return $this->createSuspendedResponse($exception); + } + $content = json_decode($request->getContent(), true); $email = is_array($content) && isset($content['email']) && is_string($content['email']) ? $content['email'] @@ -92,6 +102,16 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler return $this->createFailureResponse($result); } + private function createSuspendedResponse(CustomUserMessageAccountStatusException $exception): JsonResponse + { + return new JsonResponse([ + 'type' => '/errors/account-suspended', + 'title' => 'Compte suspendu', + 'status' => Response::HTTP_FORBIDDEN, + 'detail' => $exception->getMessageKey(), + ], Response::HTTP_FORBIDDEN); + } + private function createBlockedResponse(LoginRateLimitResult $result): JsonResponse { $response = new JsonResponse([ diff --git a/backend/src/Administration/Infrastructure/Security/UserVoter.php b/backend/src/Administration/Infrastructure/Security/UserVoter.php new file mode 100644 index 0000000..76608e7 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/UserVoter.php @@ -0,0 +1,111 @@ + + */ +final class UserVoter extends Voter +{ + public const string VIEW = 'USER_VIEW'; + public const string CREATE = 'USER_CREATE'; + public const string BLOCK = 'USER_BLOCK'; + public const string UNBLOCK = 'USER_UNBLOCK'; + public const string RESEND_INVITATION = 'USER_RESEND_INVITATION'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CREATE, + self::BLOCK, + self::UNBLOCK, + self::RESEND_INVITATION, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) { + return false; + } + + if ($subject === null) { + return true; + } + + return $subject instanceof User || $subject instanceof UserResource; + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof UserInterface) { + return false; + } + + $roles = $user->getRoles(); + + return match ($attribute) { + self::VIEW => $this->canView($roles), + self::CREATE, self::BLOCK, self::UNBLOCK, self::RESEND_INVITATION => $this->canManage($roles), + default => false, + }; + } + + /** + * @param string[] $roles + */ + private function canView(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + Role::SECRETARIAT->value, + ]); + } + + /** + * @param string[] $roles + */ + private function canManage(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $userRoles + * @param string[] $allowedRoles + */ + private function hasAnyRole(array $userRoles, array $allowedRoles): bool + { + foreach ($userRoles as $role) { + if (in_array($role, $allowedRoles, true)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Shared/Infrastructure/Tenant/TenantUrlBuilder.php b/backend/src/Shared/Infrastructure/Tenant/TenantUrlBuilder.php new file mode 100644 index 0000000..dc6449b --- /dev/null +++ b/backend/src/Shared/Infrastructure/Tenant/TenantUrlBuilder.php @@ -0,0 +1,33 @@ +tenantRegistry->getConfig( + TenantId::fromString((string) $tenantId), + ); + + $parsed = parse_url($this->appUrl); + $scheme = $parsed['scheme'] ?? 'https'; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + + return $scheme . '://' . $tenantConfig->subdomain . '.' . $this->baseDomain . $port . $path; + } +} diff --git a/backend/templates/emails/invitation.html.twig b/backend/templates/emails/invitation.html.twig new file mode 100644 index 0000000..7bc3aa9 --- /dev/null +++ b/backend/templates/emails/invitation.html.twig @@ -0,0 +1,98 @@ + + + + + + Invitation - Classeo + + + +
+

Classeo

+
+ +
+

Bienvenue sur Classeo !

+ +

Bonjour {{ firstName }},

+ +

Vous avez été invité(e) à rejoindre Classeo en tant que {{ role }}.

+ +
+

Cliquez sur le bouton ci-dessous pour activer votre compte et définir votre mot de passe.

+
+ +

+ Activer mon compte +

+ +
+

Ce lien expire dans 7 jours.

+

Si vous ne pouvez pas cliquer sur le bouton, copiez ce lien dans votre navigateur :

+

{{ activationUrl }}

+
+
+ + + + diff --git a/backend/tests/Unit/Administration/Application/Command/BlockUser/BlockUserHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/BlockUser/BlockUserHandlerTest.php new file mode 100644 index 0000000..1bc3f76 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/BlockUser/BlockUserHandlerTest.php @@ -0,0 +1,114 @@ +userRepository = new InMemoryUserRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-10 15:00:00'); + } + }; + $this->handler = new BlockUserHandler($this->userRepository, $this->clock); + } + + #[Test] + public function blocksUserSuccessfully(): void + { + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable('2026-02-02 10:00:00'), + new ConsentementParentalPolicy($this->clock), + ); + $this->userRepository->save($user); + + $command = new BlockUserCommand( + userId: (string) $user->id, + reason: 'Comportement inapproprié', + ); + + ($this->handler)($command); + + $updated = $this->userRepository->get($user->id); + self::assertSame(StatutCompte::SUSPENDU, $updated->statut); + self::assertSame('Comportement inapproprié', $updated->blockedReason); + self::assertNotNull($updated->blockedAt); + } + + #[Test] + public function throwsWhenUserAlreadySuspendu(): void + { + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable('2026-02-02 10:00:00'), + new ConsentementParentalPolicy($this->clock), + ); + $user->bloquer('Première raison', new DateTimeImmutable('2026-02-08 10:00:00')); + $this->userRepository->save($user); + + $this->expectException(UtilisateurNonBlocableException::class); + + ($this->handler)(new BlockUserCommand( + userId: (string) $user->id, + reason: 'Seconde raison', + )); + } + + #[Test] + public function throwsWhenUserNotFound(): void + { + $this->expectException(UserNotFoundException::class); + + ($this->handler)(new BlockUserCommand( + userId: (string) UserId::generate(), + reason: 'Raison', + )); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/InviteUser/InviteUserHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/InviteUser/InviteUserHandlerTest.php new file mode 100644 index 0000000..6bed685 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/InviteUser/InviteUserHandlerTest.php @@ -0,0 +1,109 @@ +userRepository = new InMemoryUserRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-07 10:00:00'); + } + }; + $this->handler = new InviteUserHandler($this->userRepository, $this->clock); + } + + #[Test] + public function invitesUserSuccessfully(): void + { + $command = new InviteUserCommand( + tenantId: self::TENANT_ID, + schoolName: self::SCHOOL_NAME, + email: 'teacher@example.com', + role: Role::PROF->value, + firstName: 'Jean', + lastName: 'Dupont', + ); + + $user = ($this->handler)($command); + + self::assertSame(StatutCompte::EN_ATTENTE, $user->statut); + self::assertSame('Jean', $user->firstName); + self::assertSame('Dupont', $user->lastName); + self::assertSame('teacher@example.com', (string) $user->email); + self::assertNotNull($user->invitedAt); + } + + #[Test] + public function throwsWhenEmailAlreadyUsedInTenant(): void + { + // Pre-populate with existing user + $existingUser = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + firstName: 'Existing', + lastName: 'User', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $this->userRepository->save($existingUser); + + $command = new InviteUserCommand( + tenantId: self::TENANT_ID, + schoolName: self::SCHOOL_NAME, + email: 'teacher@example.com', + role: Role::PROF->value, + firstName: 'Jean', + lastName: 'Dupont', + ); + + $this->expectException(EmailDejaUtiliseeException::class); + + ($this->handler)($command); + } + + #[Test] + public function savesUserToRepository(): void + { + $command = new InviteUserCommand( + tenantId: self::TENANT_ID, + schoolName: self::SCHOOL_NAME, + email: 'teacher@example.com', + role: Role::PROF->value, + firstName: 'Jean', + lastName: 'Dupont', + ); + + $user = ($this->handler)($command); + + $found = $this->userRepository->get($user->id); + self::assertSame((string) $user->id, (string) $found->id); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ResendInvitation/ResendInvitationHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ResendInvitation/ResendInvitationHandlerTest.php new file mode 100644 index 0000000..bae4ccc --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ResendInvitation/ResendInvitationHandlerTest.php @@ -0,0 +1,96 @@ +userRepository = new InMemoryUserRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-14 10:00:00'); + } + }; + $this->handler = new ResendInvitationHandler($this->userRepository, $this->clock); + } + + #[Test] + public function resendsInvitationSuccessfully(): void + { + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $this->userRepository->save($user); + + $command = new ResendInvitationCommand(userId: (string) $user->id); + + ($this->handler)($command); + + $updated = $this->userRepository->get($user->id); + self::assertEquals(new DateTimeImmutable('2026-02-14 10:00:00'), $updated->invitedAt); + } + + #[Test] + public function throwsWhenUserIsActive(): void + { + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable('2026-02-02 10:00:00'), + new ConsentementParentalPolicy($this->clock), + ); + $this->userRepository->save($user); + + $this->expectException(UtilisateurDejaInviteException::class); + + ($this->handler)(new ResendInvitationCommand(userId: (string) $user->id)); + } + + #[Test] + public function throwsWhenUserNotFound(): void + { + $this->expectException(UserNotFoundException::class); + + ($this->handler)(new ResendInvitationCommand(userId: (string) UserId::generate())); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UnblockUser/UnblockUserHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UnblockUser/UnblockUserHandlerTest.php new file mode 100644 index 0000000..f5f3c08 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/UnblockUser/UnblockUserHandlerTest.php @@ -0,0 +1,107 @@ +userRepository = new InMemoryUserRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-12 10:00:00'); + } + }; + $this->handler = new UnblockUserHandler($this->userRepository, $this->clock); + } + + #[Test] + public function unblocksUserSuccessfully(): void + { + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable('2026-02-02 10:00:00'), + new ConsentementParentalPolicy($this->clock), + ); + $user->bloquer('Comportement inapproprié', new DateTimeImmutable('2026-02-10 15:00:00')); + $this->userRepository->save($user); + + $command = new UnblockUserCommand( + userId: (string) $user->id, + ); + + ($this->handler)($command); + + $updated = $this->userRepository->get($user->id); + self::assertSame(StatutCompte::ACTIF, $updated->statut); + self::assertNull($updated->blockedAt); + self::assertNull($updated->blockedReason); + } + + #[Test] + public function throwsWhenUserNotSuspendu(): void + { + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable('2026-02-02 10:00:00'), + new ConsentementParentalPolicy($this->clock), + ); + $this->userRepository->save($user); + + $this->expectException(UtilisateurNonDeblocableException::class); + + ($this->handler)(new UnblockUserCommand(userId: (string) $user->id)); + } + + #[Test] + public function throwsWhenUserNotFound(): void + { + $this->expectException(UserNotFoundException::class); + + ($this->handler)(new UnblockUserCommand(userId: (string) UserId::generate())); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php new file mode 100644 index 0000000..1add1cb --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php @@ -0,0 +1,174 @@ +userRepository = new InMemoryUserRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-07 10:00:00'); + } + }; + $this->handler = new GetUsersHandler($this->userRepository, $this->clock); + } + + #[Test] + public function returnsAllUsersForTenant(): void + { + $this->seedUsers(); + + $query = new GetUsersQuery(tenantId: self::TENANT_ID); + $result = ($this->handler)($query); + + self::assertCount(3, $result); + } + + #[Test] + public function filtersUsersByRole(): void + { + $this->seedUsers(); + + $query = new GetUsersQuery( + tenantId: self::TENANT_ID, + role: Role::PROF->value, + ); + $result = ($this->handler)($query); + + self::assertCount(2, $result); + foreach ($result as $dto) { + self::assertSame(Role::PROF->value, $dto->role); + } + } + + #[Test] + public function filtersUsersByStatut(): void + { + $this->seedUsers(); + + $query = new GetUsersQuery( + tenantId: self::TENANT_ID, + statut: 'pending', + ); + $result = ($this->handler)($query); + + self::assertCount(2, $result); + foreach ($result as $dto) { + self::assertSame('pending', $dto->statut); + } + } + + #[Test] + public function excludesUsersFromOtherTenants(): void + { + $this->seedUsers(); + + // Add user to different tenant + $otherUser = User::inviter( + email: new Email('other@example.com'), + role: Role::ADMIN, + tenantId: TenantId::fromString(self::OTHER_TENANT_ID), + schoolName: 'Autre École', + firstName: 'Autre', + lastName: 'User', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $this->userRepository->save($otherUser); + + $query = new GetUsersQuery(tenantId: self::TENANT_ID); + $result = ($this->handler)($query); + + self::assertCount(3, $result); + } + + #[Test] + public function calculatesInvitationExpiree(): void + { + // Invited 10 days ago — should be expired + $user = User::inviter( + email: new Email('old@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + firstName: 'Old', + lastName: 'Invitation', + invitedAt: new DateTimeImmutable('2026-01-25 10:00:00'), + ); + $this->userRepository->save($user); + + $query = new GetUsersQuery(tenantId: self::TENANT_ID); + $result = ($this->handler)($query); + + self::assertCount(1, $result); + self::assertTrue($result[0]->invitationExpiree); + } + + private function seedUsers(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + + $teacher1 = User::inviter( + email: new Email('teacher1@example.com'), + role: Role::PROF, + tenantId: $tenantId, + schoolName: 'École Alpha', + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $this->userRepository->save($teacher1); + + $teacher2 = User::inviter( + email: new Email('teacher2@example.com'), + role: Role::PROF, + tenantId: $tenantId, + schoolName: 'École Alpha', + firstName: 'Marie', + lastName: 'Martin', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + // Activate teacher2 + $teacher2->activer( + '$argon2id$hashed', + new DateTimeImmutable('2026-02-02 10:00:00'), + new ConsentementParentalPolicy($this->clock), + ); + $this->userRepository->save($teacher2); + + $parent = User::inviter( + email: new Email('parent@example.com'), + role: Role::PARENT, + tenantId: $tenantId, + schoolName: 'École Alpha', + firstName: 'Pierre', + lastName: 'Parent', + invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $this->userRepository->save($parent); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/User/UserInvitationTest.php b/backend/tests/Unit/Administration/Domain/Model/User/UserInvitationTest.php new file mode 100644 index 0000000..f504454 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/User/UserInvitationTest.php @@ -0,0 +1,323 @@ +clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-07 10:00:00'); + } + }; + } + + #[Test] + public function inviterCreatesUserWithPendingStatusAndRecordsInvitedAt(): void + { + $invitedAt = new DateTimeImmutable('2026-02-07 10:00:00'); + + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: $invitedAt, + ); + + self::assertSame(StatutCompte::EN_ATTENTE, $user->statut); + self::assertSame('Jean', $user->firstName); + self::assertSame('Dupont', $user->lastName); + self::assertEquals($invitedAt, $user->invitedAt); + self::assertNull($user->hashedPassword); + self::assertNull($user->activatedAt); + self::assertNull($user->blockedAt); + self::assertNull($user->blockedReason); + } + + #[Test] + public function inviterRecordsUtilisateurInviteEvent(): void + { + $user = $this->inviteUser(); + + $events = $user->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(UtilisateurInvite::class, $events[0]); + } + + #[Test] + public function renvoyerInvitationUpdatesInvitedAtAndRecordsEvent(): void + { + $user = $this->inviteUser(); + $user->pullDomainEvents(); + + $newInvitedAt = new DateTimeImmutable('2026-02-14 10:00:00'); + $user->renvoyerInvitation($newInvitedAt); + + self::assertEquals($newInvitedAt, $user->invitedAt); + + $events = $user->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(InvitationRenvoyee::class, $events[0]); + } + + #[Test] + public function renvoyerInvitationThrowsWhenUserIsActive(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + + $this->expectException(UtilisateurDejaInviteException::class); + + $user->renvoyerInvitation(new DateTimeImmutable('2026-02-14 10:00:00')); + } + + #[Test] + public function bloquerSetsStatusToSuspenduWithReasonAndDate(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + $user->pullDomainEvents(); + + $blockedAt = new DateTimeImmutable('2026-02-10 15:00:00'); + $user->bloquer('Comportement inapproprié', $blockedAt); + + self::assertSame(StatutCompte::SUSPENDU, $user->statut); + self::assertEquals($blockedAt, $user->blockedAt); + self::assertSame('Comportement inapproprié', $user->blockedReason); + self::assertFalse($user->peutSeConnecter()); + } + + #[Test] + public function bloquerRecordsUtilisateurBloqueEvent(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + $user->pullDomainEvents(); + + $user->bloquer('Raison de test', new DateTimeImmutable('2026-02-10 15:00:00')); + + $events = $user->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(UtilisateurBloque::class, $events[0]); + } + + #[Test] + public function bloquerThrowsWhenAlreadySuspendu(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + $user->bloquer('Première raison', new DateTimeImmutable('2026-02-10 15:00:00')); + + $this->expectException(UtilisateurNonBlocableException::class); + + $user->bloquer('Seconde raison', new DateTimeImmutable('2026-02-11 15:00:00')); + } + + #[Test] + public function bloquerThrowsWhenEnAttente(): void + { + $user = $this->inviteUser(); + + $this->expectException(UtilisateurNonBlocableException::class); + + $user->bloquer('Raison de test', new DateTimeImmutable('2026-02-10 15:00:00')); + } + + #[Test] + public function bloquerThrowsWhenConsentementRequis(): void + { + $user = User::reconstitute( + id: UserId::generate(), + email: new Email('minor@example.com'), + role: Role::ELEVE, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + statut: StatutCompte::CONSENTEMENT_REQUIS, + dateNaissance: new DateTimeImmutable('2015-01-01'), + createdAt: new DateTimeImmutable('2026-02-01 10:00:00'), + hashedPassword: null, + activatedAt: null, + consentementParental: null, + ); + + $this->expectException(UtilisateurNonBlocableException::class); + + $user->bloquer('Raison de test', new DateTimeImmutable('2026-02-10 15:00:00')); + } + + #[Test] + public function debloquerRestoresActiveStatusAndClearsBlockedInfo(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + $user->bloquer('Raison de test', new DateTimeImmutable('2026-02-10 15:00:00')); + $user->pullDomainEvents(); + + $user->debloquer(new DateTimeImmutable('2026-02-12 10:00:00')); + + self::assertSame(StatutCompte::ACTIF, $user->statut); + self::assertNull($user->blockedAt); + self::assertNull($user->blockedReason); + } + + #[Test] + public function debloquerRecordsUtilisateurDebloqueEvent(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + $user->bloquer('Raison de test', new DateTimeImmutable('2026-02-10 15:00:00')); + $user->pullDomainEvents(); + + $user->debloquer(new DateTimeImmutable('2026-02-12 10:00:00')); + + $events = $user->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(UtilisateurDebloque::class, $events[0]); + } + + #[Test] + public function debloquerThrowsWhenNotSuspendu(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + + $this->expectException(UtilisateurNonDeblocableException::class); + + $user->debloquer(new DateTimeImmutable('2026-02-12 10:00:00')); + } + + #[Test] + public function debloquerThrowsWhenEnAttente(): void + { + $user = $this->inviteUser(); + + $this->expectException(UtilisateurNonDeblocableException::class); + + $user->debloquer(new DateTimeImmutable('2026-02-12 10:00:00')); + } + + #[Test] + public function estInvitationExpireeReturnsTrueAfter7Days(): void + { + $invitedAt = new DateTimeImmutable('2026-01-30 10:00:00'); + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: $invitedAt, + ); + + // 8 jours après + $checkAt = new DateTimeImmutable('2026-02-07 10:00:01'); + self::assertTrue($user->estInvitationExpiree($checkAt)); + } + + #[Test] + public function estInvitationExpireeReturnsFalseWithin7Days(): void + { + $invitedAt = new DateTimeImmutable('2026-02-05 10:00:00'); + $user = User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: $invitedAt, + ); + + // 2 jours après + $checkAt = new DateTimeImmutable('2026-02-07 10:00:00'); + self::assertFalse($user->estInvitationExpiree($checkAt)); + } + + #[Test] + public function estInvitationExpireeReturnsFalseForActiveUser(): void + { + $user = $this->inviteUser(); + $user->activer( + '$argon2id$hashed', + new DateTimeImmutable(), + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), + ); + + // Même longtemps après, un utilisateur actif n'a pas d'invitation expirée + $checkAt = new DateTimeImmutable('2027-01-01 10:00:00'); + self::assertFalse($user->estInvitationExpiree($checkAt)); + } + + private function inviteUser(): User + { + return User::inviter( + email: new Email('teacher@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + firstName: 'Jean', + lastName: 'Dupont', + invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php index 79b7d36..0594838 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php @@ -172,6 +172,11 @@ final class ActivateAccountProcessorTest extends TestCase { throw UserNotFoundException::withId($id); } + + public function findAllByTenant(TenantId $tenantId): array + { + return []; + } }; $consentementPolicy = new ConsentementParentalPolicy($this->clock); diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php index 11b2d74..ef3dc4d 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php @@ -23,6 +23,7 @@ use PHPUnit\Framework\TestCase; use stdClass; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; final class DatabaseUserProviderTest extends TestCase @@ -83,6 +84,22 @@ final class DatabaseUserProviderTest extends TestCase $provider->loadUserByIdentifier('user@example.com'); } + #[Test] + public function loadUserByIdentifierThrowsAccountStatusExceptionForSuspendedUser(): void + { + $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID); + $domainUser = $this->createUser($tenantId, StatutCompte::SUSPENDU, hashedPassword: '$argon2id$hash'); + + $repository = $this->createMock(UserRepository::class); + $repository->method('findByEmail')->willReturn($domainUser); + + $provider = $this->createProvider($repository, 'ecole-alpha.classeo.local'); + + $this->expectException(CustomUserMessageAccountStatusException::class); + + $provider->loadUserByIdentifier('user@example.com'); + } + #[Test] public function loadUserByIdentifierThrowsForUnknownTenant(): void { diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/LoginFailureHandlerTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/LoginFailureHandlerTest.php new file mode 100644 index 0000000..5d51ccc --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/LoginFailureHandlerTest.php @@ -0,0 +1,101 @@ +createMock(LoginRateLimiterInterface::class); + $rateLimiter->expects(self::never())->method('recordFailure'); + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->expects(self::never())->method('dispatch'); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-07 10:00:00'); + } + }; + + $tenantResolver = $this->createMock(TenantResolver::class); + + $handler = new LoginFailureHandler($rateLimiter, $eventBus, $clock, $tenantResolver, $this->createMetricsCollector()); + + $request = Request::create('/api/login', 'POST', [], [], [], [], json_encode(['email' => 'blocked@example.com', 'password' => 'test'])); + $exception = new CustomUserMessageAccountStatusException('Votre compte a été suspendu. Contactez votre établissement.'); + + $response = $handler->onAuthenticationFailure($request, $exception); + + self::assertSame(403, $response->getStatusCode()); + $data = json_decode($response->getContent(), true); + self::assertSame('/errors/account-suspended', $data['type']); + self::assertSame('Compte suspendu', $data['title']); + } + + #[Test] + public function standardFailureReturns401WithRateLimiting(): void + { + $rateLimiter = $this->createMock(LoginRateLimiterInterface::class); + $rateLimiter->expects(self::once()) + ->method('recordFailure') + ->willReturn(LoginRateLimitResult::allowed(1, 0, false)); + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch')->willReturnCallback( + static fn (object $message) => new Envelope($message), + ); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-07 10:00:00'); + } + }; + + $tenantResolver = $this->createMock(TenantResolver::class); + $tenantResolver->method('resolve')->willThrowException(TenantNotFoundException::withSubdomain('unknown')); + + $handler = new LoginFailureHandler($rateLimiter, $eventBus, $clock, $tenantResolver, $this->createMetricsCollector()); + + $request = Request::create('/api/login', 'POST', [], [], [], [], json_encode(['email' => 'user@example.com', 'password' => 'wrong'])); + $exception = new AuthenticationException('Invalid credentials.'); + + $response = $handler->onAuthenticationFailure($request, $exception); + + self::assertSame(401, $response->getStatusCode()); + $data = json_decode($response->getContent(), true); + self::assertSame('/errors/authentication-failed', $data['type']); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/UserVoterTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/UserVoterTest.php new file mode 100644 index 0000000..3e2ff9c --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/UserVoterTest.php @@ -0,0 +1,134 @@ +voter = new UserVoter(); + } + + #[Test] + public function itAbstainsForUnrelatedAttributes(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn(['ROLE_ADMIN']); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + $result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']); + + self::assertSame(UserVoter::ACCESS_ABSTAIN, $result); + } + + #[Test] + public function itDeniesAccessToUnauthenticatedUsers(): void + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(null); + + $result = $this->voter->vote($token, null, [UserVoter::VIEW]); + + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsViewToSuperAdmin(): void + { + $result = $this->voteWithRole('ROLE_SUPER_ADMIN', UserVoter::VIEW); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', UserVoter::VIEW); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToSecretariat(): void + { + $result = $this->voteWithRole('ROLE_SECRETARIAT', UserVoter::VIEW); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesViewToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', UserVoter::VIEW); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesViewToParent(): void + { + $result = $this->voteWithRole('ROLE_PARENT', UserVoter::VIEW); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsCreateToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', UserVoter::CREATE); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesCreateToSecretariat(): void + { + $result = $this->voteWithRole('ROLE_SECRETARIAT', UserVoter::CREATE); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsBlockToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', UserVoter::BLOCK); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesBlockToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', UserVoter::BLOCK); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsUnblockToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', UserVoter::UNBLOCK); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesUnblockToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', UserVoter::UNBLOCK); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + private function voteWithRole(string $role, string $attribute): int + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn([$role]); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + return $this->voter->vote($token, null, [$attribute]); + } +} diff --git a/frontend/e2e/user-blocking.spec.ts b/frontend/e2e/user-blocking.spec.ts new file mode 100644 index 0000000..03743ca --- /dev/null +++ b/frontend/e2e/user-blocking.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-blocking-admin@example.com'; +const ADMIN_PASSWORD = 'BlockingTest123'; +const TARGET_EMAIL = 'e2e-blocking-target@example.com'; +const TARGET_PASSWORD = 'TargetUser123'; + +test.describe('User Blocking', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create target user to be blocked + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /se connecter/i }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + } + + test('admin can block a user and sees blocked status', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + + // Wait for users table to load + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + // Find the target user row + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(targetRow).toBeVisible(); + + // Click "Bloquer" button + await targetRow.getByRole('button', { name: /bloquer/i }).click(); + + // Block modal should appear + await expect(page.locator('#block-modal-title')).toBeVisible(); + + // Fill in the reason + await page.locator('#block-reason').fill('Comportement inapproprié en E2E'); + + // Confirm the block + await page.getByRole('button', { name: /confirmer le blocage/i }).click(); + + // Wait for the success message + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 5000 }); + + // Verify the user status changed to "Suspendu" + const updatedRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(updatedRow.locator('.status-blocked')).toContainText('Suspendu'); + + // Verify the reason is displayed + await expect(updatedRow.locator('.blocked-reason')).toContainText('Comportement inapproprié en E2E'); + }); + + test('admin can unblock a suspended user', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + // Find the suspended target user row + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(targetRow).toBeVisible(); + + // "Débloquer" button should be visible for suspended user + const unblockButton = targetRow.getByRole('button', { name: /débloquer/i }); + await expect(unblockButton).toBeVisible(); + + // Click unblock + await unblockButton.click(); + + // Wait for the success message + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 5000 }); + + // Verify the user status changed back to "Actif" + const updatedRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(updatedRow.locator('.status-active')).toContainText('Actif'); + }); + + test('blocked user sees specific error on login', async ({ page }) => { + // First, block the user again + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await targetRow.getByRole('button', { name: /bloquer/i }).click(); + await page.locator('#block-reason').fill('Bloqué pour test login'); + await page.getByRole('button', { name: /confirmer le blocage/i }).click(); + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 5000 }); + + // Logout + await page.getByRole('button', { name: /déconnexion/i }).click(); + + // Try to log in as the blocked user + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TARGET_EMAIL); + await page.locator('#password').fill(TARGET_PASSWORD); + await page.getByRole('button', { name: /se connecter/i }).click(); + + // Should see a suspended account error, not the generic credentials error + const errorBanner = page.locator('.error-banner.account-suspended'); + await expect(errorBanner).toBeVisible({ timeout: 5000 }); + await expect(errorBanner).toContainText(/suspendu|contactez/i); + }); + + test('admin cannot block themselves', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + // Find the admin's own row + const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) }); + await expect(adminRow).toBeVisible(); + + // "Bloquer" button should NOT be present on the admin's own row + await expect(adminRow.getByRole('button', { name: /^bloquer$/i })).not.toBeVisible(); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 6db64af..69df5a3 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -79,7 +79,8 @@ export default tseslint.config( fetch: 'readonly', HTMLDivElement: 'readonly', setInterval: 'readonly', - clearInterval: 'readonly' + clearInterval: 'readonly', + URLSearchParams: 'readonly' } }, plugins: { diff --git a/frontend/src/lib/auth/auth.svelte.ts b/frontend/src/lib/auth/auth.svelte.ts index 2084122..1eeeb5e 100644 --- a/frontend/src/lib/auth/auth.svelte.ts +++ b/frontend/src/lib/auth/auth.svelte.ts @@ -45,9 +45,10 @@ function parseJwtPayload(token: string): Record | null { function extractUserId(token: string): string | null { const payload = parseJwtPayload(token); if (!payload) return null; - // JWT 'sub' claim contains the user ID - const sub = payload['sub']; - return typeof sub === 'string' ? sub : null; + // JWT 'user_id' claim contains the UUID (set by JwtPayloadEnricher) + // Note: 'sub' contains the email (Lexik default), not the UUID + const userId = payload['user_id']; + return typeof userId === 'string' ? userId : null; } export interface LoginCredentials { @@ -59,7 +60,7 @@ export interface LoginCredentials { export interface LoginResult { success: boolean; error?: { - type: 'invalid_credentials' | 'rate_limited' | 'captcha_required' | 'captcha_invalid' | 'unknown'; + type: 'invalid_credentials' | 'rate_limited' | 'captcha_required' | 'captcha_invalid' | 'account_suspended' | 'unknown'; message: string; retryAfter?: number | undefined; delay?: number | undefined; @@ -132,6 +133,17 @@ export async function login(credentials: LoginCredentials): Promise }; } + // Compte suspendu (403) + if (response.status === 403 && error.type === '/errors/account-suspended') { + return { + success: false, + error: { + type: 'account_suspended', + message: error.detail, + }, + }; + } + // CAPTCHA invalide (400) if (response.status === 400 && error.type === '/errors/captcha-invalid') { return { diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts index b80b7db..b16b07a 100644 --- a/frontend/src/lib/auth/index.ts +++ b/frontend/src/lib/auth/index.ts @@ -5,6 +5,7 @@ export { authenticatedFetch, isAuthenticated, getAccessToken, + getCurrentUserId, type LoginCredentials, type LoginResult, } from './auth.svelte'; diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index 49a9cdc..d7f2d21 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -26,11 +26,11 @@

Actions de configuration

- + Inviter et gérer + 🏫 Configurer les classes diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 6f6ff0e..22501cb 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -24,6 +24,7 @@ } // Determine which admin section is active + const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users')); const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes')); const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects')); const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods')); @@ -38,6 +39,7 @@