From e930c505df6e8392aad2637418e22677bb14d167 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 10 Feb 2026 07:57:43 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Attribution=20de=20r=C3=B4les=20multipl?= =?UTF-8?q?es=20par=20utilisateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/config/packages/security.yaml | 10 + backend/config/services.yaml | 4 + .../Command/AssignRole/AssignRoleCommand.php | 15 ++ .../Command/AssignRole/AssignRoleHandler.php | 46 ++++ .../Command/InviteUser/InviteUserCommand.php | 20 ++ .../Command/InviteUser/InviteUserHandler.php | 30 ++- .../Command/RemoveRole/RemoveRoleCommand.php | 15 ++ .../Command/RemoveRole/RemoveRoleHandler.php | 46 ++++ .../UpdateUserRolesCommand.php | 27 ++ .../UpdateUserRolesHandler.php | 83 ++++++ .../Application/Port/ActiveRoleStore.php | 32 +++ .../Query/GetUsers/GetUsersHandler.php | 2 +- .../Application/Query/GetUsers/UserDto.php | 7 + .../Application/Service/RoleContext.php | 48 ++++ .../Domain/Event/RoleAttribue.php | 36 +++ .../Domain/Event/RoleRetire.php | 36 +++ .../ActivationTokenAlreadyUsedException.php | 4 +- .../ActivationTokenExpiredException.php | 4 +- .../ActivationTokenNotFoundException.php | 4 +- ...GradingModeWithExistingGradesException.php | 4 +- .../Exception/ClassNameInvalideException.php | 4 +- .../ClasseDejaExistanteException.php | 4 +- .../ClasseNonSupprimableException.php | 4 +- .../Exception/ClasseNotFoundException.php | 4 +- .../Exception/CompteNonActivableException.php | 4 +- .../DernierRoleNonRetirableException.php | 21 ++ .../Exception/EmailDejaUtiliseeException.php | 4 +- .../Exception/EmailInvalideException.php | 4 +- .../GradingConfigurationNotFoundException.php | 4 +- .../Exception/InvalidPeriodCountException.php | 4 +- .../Exception/InvalidPeriodDatesException.php | 4 +- ...PasswordResetTokenAlreadyUsedException.php | 4 +- .../PasswordResetTokenExpiredException.php | 4 +- .../PasswordResetTokenNotFoundException.php | 4 +- .../Exception/PeriodeAvecNotesException.php | 4 +- .../Exception/PeriodeNonTrouveeException.php | 4 +- .../PeriodesDejaConfigureesException.php | 4 +- .../PeriodesNonConfigureesException.php | 4 +- .../Exception/PeriodsCoverageGapException.php | 4 +- .../Exception/PeriodsOverlapException.php | 4 +- .../Exception/RoleDejaAttribueException.php | 23 ++ .../Exception/RoleNonAttribueException.php | 23 ++ .../Exception/SessionNotFoundException.php | 4 +- .../SubjectCodeInvalideException.php | 4 +- .../SubjectColorInvalideException.php | 4 +- .../SubjectDejaExistanteException.php | 4 +- .../SubjectNameInvalideException.php | 4 +- .../SubjectNonSupprimableException.php | 4 +- .../Exception/SubjectNotFoundException.php | 4 +- .../TokenAlreadyRotatedException.php | 4 +- .../TokenConsumptionInProgressException.php | 4 +- .../TokenReplayDetectedException.php | 4 +- .../Exception/UserNotFoundException.php | 4 +- .../UtilisateurDejaInviteException.php | 4 +- .../UtilisateurNonBlocableException.php | 4 +- .../UtilisateurNonDeblocableException.php | 4 +- .../Administration/Domain/Model/User/User.php | 112 +++++++- .../Api/Controller/LogoutController.php | 11 + .../Api/Processor/InviteUserProcessor.php | 21 ++ .../Api/Processor/SwitchRoleProcessor.php | 70 +++++ .../Processor/UpdateUserRolesProcessor.php | 114 +++++++++ .../Api/Provider/MyRolesProvider.php | 62 +++++ .../Api/Resource/MyRolesOutput.php | 33 +++ .../Api/Resource/SwitchRoleInput.php | 27 ++ .../Api/Resource/SwitchRoleOutput.php | 14 + .../Api/Resource/UserResource.php | 21 ++ .../Console/CreateTestUserCommand.php | 2 +- .../Persistence/Cache/CacheUserRepository.php | 21 +- .../Security/LoginSuccessHandler.php | 11 + .../Security/SecurityUserFactory.php | 10 +- .../Infrastructure/Security/UserVoter.php | 4 +- .../Service/CacheActiveRoleStore.php | 70 +++++ .../Command/AssignRoleHandlerTest.php | 116 +++++++++ .../Command/RemoveRoleHandlerTest.php | 126 +++++++++ .../Command/UpdateUserRolesHandlerTest.php | 119 +++++++++ .../Application/Service/RoleContextTest.php | 139 ++++++++++ .../Domain/Model/User/UserInvitationTest.php | 2 +- .../Domain/Model/User/UserRoleTest.php | 156 ++++++++++++ .../Api/Controller/LogoutControllerTest.php | 7 +- .../CreateTestActivationTokenCommandTest.php | 2 +- .../Security/DatabaseUserProviderTest.php | 2 +- .../Security/LoginSuccessHandlerTest.php | 18 +- .../Security/SecurityUserTest.php | 2 +- .../Infrastructure/Security/UserVoterTest.php | 42 +++ frontend/e2e/pedagogy.spec.ts | 9 +- .../RoleSwitcher/RoleSwitcher.svelte | 113 ++++++++ frontend/src/lib/features/roles/api/roles.ts | 86 +++++++ .../lib/features/roles/roleContext.svelte.ts | 111 ++++++++ frontend/src/routes/+layout.svelte | 2 + frontend/src/routes/admin/+layout.svelte | 10 + frontend/src/routes/admin/users/+page.svelte | 241 ++++++++++++++++-- frontend/src/routes/dashboard/+layout.svelte | 20 +- frontend/src/routes/dashboard/+page.svelte | 94 +++++-- 93 files changed, 2527 insertions(+), 165 deletions(-) create mode 100644 backend/src/Administration/Application/Command/AssignRole/AssignRoleCommand.php create mode 100644 backend/src/Administration/Application/Command/AssignRole/AssignRoleHandler.php create mode 100644 backend/src/Administration/Application/Command/RemoveRole/RemoveRoleCommand.php create mode 100644 backend/src/Administration/Application/Command/RemoveRole/RemoveRoleHandler.php create mode 100644 backend/src/Administration/Application/Command/UpdateUserRoles/UpdateUserRolesCommand.php create mode 100644 backend/src/Administration/Application/Command/UpdateUserRoles/UpdateUserRolesHandler.php create mode 100644 backend/src/Administration/Application/Port/ActiveRoleStore.php create mode 100644 backend/src/Administration/Application/Service/RoleContext.php create mode 100644 backend/src/Administration/Domain/Event/RoleAttribue.php create mode 100644 backend/src/Administration/Domain/Event/RoleRetire.php create mode 100644 backend/src/Administration/Domain/Exception/DernierRoleNonRetirableException.php create mode 100644 backend/src/Administration/Domain/Exception/RoleDejaAttribueException.php create mode 100644 backend/src/Administration/Domain/Exception/RoleNonAttribueException.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/SwitchRoleProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/MyRolesOutput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/SwitchRoleInput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/SwitchRoleOutput.php create mode 100644 backend/src/Administration/Infrastructure/Service/CacheActiveRoleStore.php create mode 100644 backend/tests/Unit/Administration/Application/Command/AssignRoleHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/RemoveRoleHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/UpdateUserRolesHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/RoleContextTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/User/UserRoleTest.php create mode 100644 frontend/src/lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte create mode 100644 frontend/src/lib/features/roles/api/roles.ts create mode 100644 frontend/src/lib/features/roles/roleContext.svelte.ts diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index dc3e186..4a802f7 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -1,4 +1,14 @@ security: + # Role hierarchy — Direction inherits read permissions on whole school (AC3, FR5) + role_hierarchy: + ROLE_SUPER_ADMIN: [ROLE_ADMIN] + ROLE_ADMIN: [ROLE_PROF, ROLE_VIE_SCOLAIRE, ROLE_SECRETARIAT] + ROLE_PROF: [ROLE_USER] + ROLE_VIE_SCOLAIRE: [ROLE_USER] + ROLE_SECRETARIAT: [ROLE_USER] + ROLE_PARENT: [ROLE_USER] + ROLE_ELEVE: [ROLE_USER] + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 07be8dc..d164b44 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -151,6 +151,10 @@ services: App\Administration\Application\Port\GradeExistenceChecker: alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker + # ActiveRoleStore (session-scoped cache for active role switching) + App\Administration\Application\Port\ActiveRoleStore: + alias: App\Administration\Infrastructure\Service\CacheActiveRoleStore + # GeoLocation Service (null implementation - no geolocation) App\Administration\Application\Port\GeoLocationService: alias: App\Administration\Infrastructure\Service\NullGeoLocationService diff --git a/backend/src/Administration/Application/Command/AssignRole/AssignRoleCommand.php b/backend/src/Administration/Application/Command/AssignRole/AssignRoleCommand.php new file mode 100644 index 0000000..3cdbe38 --- /dev/null +++ b/backend/src/Administration/Application/Command/AssignRole/AssignRoleCommand.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); + } + + $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; + } +} diff --git a/backend/src/Administration/Application/Command/InviteUser/InviteUserCommand.php b/backend/src/Administration/Application/Command/InviteUser/InviteUserCommand.php index a213673..9378031 100644 --- a/backend/src/Administration/Application/Command/InviteUser/InviteUserCommand.php +++ b/backend/src/Administration/Application/Command/InviteUser/InviteUserCommand.php @@ -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; } } diff --git a/backend/src/Administration/Application/Command/InviteUser/InviteUserHandler.php b/backend/src/Administration/Application/Command/InviteUser/InviteUserHandler.php index 1201273..5fb4990 100644 --- a/backend/src/Administration/Application/Command/InviteUser/InviteUserHandler.php +++ b/backend/src/Administration/Application/Command/InviteUser/InviteUserHandler.php @@ -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; diff --git a/backend/src/Administration/Application/Command/RemoveRole/RemoveRoleCommand.php b/backend/src/Administration/Application/Command/RemoveRole/RemoveRoleCommand.php new file mode 100644 index 0000000..f0ae37b --- /dev/null +++ b/backend/src/Administration/Application/Command/RemoveRole/RemoveRoleCommand.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); + } + + $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; + } +} diff --git a/backend/src/Administration/Application/Command/UpdateUserRoles/UpdateUserRolesCommand.php b/backend/src/Administration/Application/Command/UpdateUserRoles/UpdateUserRolesCommand.php new file mode 100644 index 0000000..f3aa574 --- /dev/null +++ b/backend/src/Administration/Application/Command/UpdateUserRoles/UpdateUserRolesCommand.php @@ -0,0 +1,27 @@ +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 $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; + } +} diff --git a/backend/src/Administration/Application/Port/ActiveRoleStore.php b/backend/src/Administration/Application/Port/ActiveRoleStore.php new file mode 100644 index 0000000..ed25d88 --- /dev/null +++ b/backend/src/Administration/Application/Port/ActiveRoleStore.php @@ -0,0 +1,32 @@ + $user->role === $filterRole, + static fn ($user) => $user->aLeRole($filterRole), ); } } diff --git a/backend/src/Administration/Application/Query/GetUsers/UserDto.php b/backend/src/Administration/Application/Query/GetUsers/UserDto.php index b8134cb..aa1242a 100644 --- a/backend/src/Administration/Application/Query/GetUsers/UserDto.php +++ b/backend/src/Administration/Application/Query/GetUsers/UserDto.php @@ -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, diff --git a/backend/src/Administration/Application/Service/RoleContext.php b/backend/src/Administration/Application/Service/RoleContext.php new file mode 100644 index 0000000..b1f8b34 --- /dev/null +++ b/backend/src/Administration/Application/Service/RoleContext.php @@ -0,0 +1,48 @@ +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); + } +} diff --git a/backend/src/Administration/Domain/Event/RoleAttribue.php b/backend/src/Administration/Domain/Event/RoleAttribue.php new file mode 100644 index 0000000..c3453e9 --- /dev/null +++ b/backend/src/Administration/Domain/Event/RoleAttribue.php @@ -0,0 +1,36 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/RoleRetire.php b/backend/src/Administration/Domain/Event/RoleRetire.php new file mode 100644 index 0000000..af48322 --- /dev/null +++ b/backend/src/Administration/Domain/Event/RoleRetire.php @@ -0,0 +1,36 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php b/backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php index 9d7025c..081838e 100644 --- a/backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php +++ b/backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\ActivationToken\ActivationTokenId; -use RuntimeException; +use DomainException; use function sprintf; -final class ActivationTokenAlreadyUsedException extends RuntimeException +final class ActivationTokenAlreadyUsedException extends DomainException { public static function forToken(ActivationTokenId $tokenId): self { diff --git a/backend/src/Administration/Domain/Exception/ActivationTokenExpiredException.php b/backend/src/Administration/Domain/Exception/ActivationTokenExpiredException.php index 641ee50..dd72a5a 100644 --- a/backend/src/Administration/Domain/Exception/ActivationTokenExpiredException.php +++ b/backend/src/Administration/Domain/Exception/ActivationTokenExpiredException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\ActivationToken\ActivationTokenId; -use RuntimeException; +use DomainException; use function sprintf; -final class ActivationTokenExpiredException extends RuntimeException +final class ActivationTokenExpiredException extends DomainException { public static function forToken(ActivationTokenId $tokenId): self { diff --git a/backend/src/Administration/Domain/Exception/ActivationTokenNotFoundException.php b/backend/src/Administration/Domain/Exception/ActivationTokenNotFoundException.php index b1e49e6..3341c0b 100644 --- a/backend/src/Administration/Domain/Exception/ActivationTokenNotFoundException.php +++ b/backend/src/Administration/Domain/Exception/ActivationTokenNotFoundException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\ActivationToken\ActivationTokenId; -use RuntimeException; +use DomainException; use function sprintf; -final class ActivationTokenNotFoundException extends RuntimeException +final class ActivationTokenNotFoundException extends DomainException { public static function withId(ActivationTokenId $tokenId): self { diff --git a/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php b/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php index fac5a35..101919e 100644 --- a/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php +++ b/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; -use RuntimeException; +use DomainException; -final class CannotChangeGradingModeWithExistingGradesException extends RuntimeException +final class CannotChangeGradingModeWithExistingGradesException extends DomainException { public function __construct() { diff --git a/backend/src/Administration/Domain/Exception/ClassNameInvalideException.php b/backend/src/Administration/Domain/Exception/ClassNameInvalideException.php index 22768cc..832cf6d 100644 --- a/backend/src/Administration/Domain/Exception/ClassNameInvalideException.php +++ b/backend/src/Administration/Domain/Exception/ClassNameInvalideException.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; -use RuntimeException; +use DomainException; use function sprintf; -final class ClassNameInvalideException extends RuntimeException +final class ClassNameInvalideException extends DomainException { public static function pourLongueur(string $value, int $min, int $max): self { diff --git a/backend/src/Administration/Domain/Exception/ClasseDejaExistanteException.php b/backend/src/Administration/Domain/Exception/ClasseDejaExistanteException.php index 23a88a1..65386de 100644 --- a/backend/src/Administration/Domain/Exception/ClasseDejaExistanteException.php +++ b/backend/src/Administration/Domain/Exception/ClasseDejaExistanteException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\SchoolClass\ClassName; -use RuntimeException; +use DomainException; use function sprintf; -final class ClasseDejaExistanteException extends RuntimeException +final class ClasseDejaExistanteException extends DomainException { public static function avecNom(ClassName $name): self { diff --git a/backend/src/Administration/Domain/Exception/ClasseNonSupprimableException.php b/backend/src/Administration/Domain/Exception/ClasseNonSupprimableException.php index 5c43ee9..47adf83 100644 --- a/backend/src/Administration/Domain/Exception/ClasseNonSupprimableException.php +++ b/backend/src/Administration/Domain/Exception/ClasseNonSupprimableException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\SchoolClass\ClassId; -use RuntimeException; +use DomainException; use function sprintf; -final class ClasseNonSupprimableException extends RuntimeException +final class ClasseNonSupprimableException extends DomainException { public static function carElevesAffectes(ClassId $classId, int $nombreEleves): self { diff --git a/backend/src/Administration/Domain/Exception/ClasseNotFoundException.php b/backend/src/Administration/Domain/Exception/ClasseNotFoundException.php index 40b9593..31cbd5c 100644 --- a/backend/src/Administration/Domain/Exception/ClasseNotFoundException.php +++ b/backend/src/Administration/Domain/Exception/ClasseNotFoundException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\SchoolClass\ClassId; -use RuntimeException; +use DomainException; use function sprintf; -final class ClasseNotFoundException extends RuntimeException +final class ClasseNotFoundException extends DomainException { public static function withId(ClassId $classId): self { diff --git a/backend/src/Administration/Domain/Exception/CompteNonActivableException.php b/backend/src/Administration/Domain/Exception/CompteNonActivableException.php index c9bef85..cb589e6 100644 --- a/backend/src/Administration/Domain/Exception/CompteNonActivableException.php +++ b/backend/src/Administration/Domain/Exception/CompteNonActivableException.php @@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\User\StatutCompte; use App\Administration\Domain\Model\User\UserId; -use RuntimeException; +use DomainException; use function sprintf; -final class CompteNonActivableException extends RuntimeException +final class CompteNonActivableException extends DomainException { public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self { diff --git a/backend/src/Administration/Domain/Exception/DernierRoleNonRetirableException.php b/backend/src/Administration/Domain/Exception/DernierRoleNonRetirableException.php new file mode 100644 index 0000000..8d5519b --- /dev/null +++ b/backend/src/Administration/Domain/Exception/DernierRoleNonRetirableException.php @@ -0,0 +1,21 @@ +label(), + $userId, + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/RoleNonAttribueException.php b/backend/src/Administration/Domain/Exception/RoleNonAttribueException.php new file mode 100644 index 0000000..57d0c71 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/RoleNonAttribueException.php @@ -0,0 +1,23 @@ +label(), + $userId, + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/SessionNotFoundException.php b/backend/src/Administration/Domain/Exception/SessionNotFoundException.php index ab8edc2..39b6e74 100644 --- a/backend/src/Administration/Domain/Exception/SessionNotFoundException.php +++ b/backend/src/Administration/Domain/Exception/SessionNotFoundException.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\RefreshToken\TokenFamilyId; -use RuntimeException; +use DomainException; use function sprintf; @@ -14,7 +14,7 @@ use function sprintf; * * @see Story 1.6 - Gestion des sessions */ -final class SessionNotFoundException extends RuntimeException +final class SessionNotFoundException extends DomainException { public function __construct(TokenFamilyId $familyId) { diff --git a/backend/src/Administration/Domain/Exception/SubjectCodeInvalideException.php b/backend/src/Administration/Domain/Exception/SubjectCodeInvalideException.php index aaec8e1..e4f562b 100644 --- a/backend/src/Administration/Domain/Exception/SubjectCodeInvalideException.php +++ b/backend/src/Administration/Domain/Exception/SubjectCodeInvalideException.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; -use RuntimeException; +use DomainException; use function sprintf; -final class SubjectCodeInvalideException extends RuntimeException +final class SubjectCodeInvalideException extends DomainException { public static function pourFormat(string $value, int $min, int $max): self { diff --git a/backend/src/Administration/Domain/Exception/SubjectColorInvalideException.php b/backend/src/Administration/Domain/Exception/SubjectColorInvalideException.php index 985a4d6..3a68f26 100644 --- a/backend/src/Administration/Domain/Exception/SubjectColorInvalideException.php +++ b/backend/src/Administration/Domain/Exception/SubjectColorInvalideException.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; -use RuntimeException; +use DomainException; use function sprintf; -final class SubjectColorInvalideException extends RuntimeException +final class SubjectColorInvalideException extends DomainException { public static function pourFormat(string $value): self { diff --git a/backend/src/Administration/Domain/Exception/SubjectDejaExistanteException.php b/backend/src/Administration/Domain/Exception/SubjectDejaExistanteException.php index 8d1231a..b61d03b 100644 --- a/backend/src/Administration/Domain/Exception/SubjectDejaExistanteException.php +++ b/backend/src/Administration/Domain/Exception/SubjectDejaExistanteException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\Subject\SubjectCode; -use RuntimeException; +use DomainException; use function sprintf; -final class SubjectDejaExistanteException extends RuntimeException +final class SubjectDejaExistanteException extends DomainException { public static function avecCode(SubjectCode $code): self { diff --git a/backend/src/Administration/Domain/Exception/SubjectNameInvalideException.php b/backend/src/Administration/Domain/Exception/SubjectNameInvalideException.php index 95a84bb..47e0b24 100644 --- a/backend/src/Administration/Domain/Exception/SubjectNameInvalideException.php +++ b/backend/src/Administration/Domain/Exception/SubjectNameInvalideException.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; -use RuntimeException; +use DomainException; use function sprintf; -final class SubjectNameInvalideException extends RuntimeException +final class SubjectNameInvalideException extends DomainException { public static function pourLongueur(string $value, int $min, int $max): self { diff --git a/backend/src/Administration/Domain/Exception/SubjectNonSupprimableException.php b/backend/src/Administration/Domain/Exception/SubjectNonSupprimableException.php index 98351b5..b8d832d 100644 --- a/backend/src/Administration/Domain/Exception/SubjectNonSupprimableException.php +++ b/backend/src/Administration/Domain/Exception/SubjectNonSupprimableException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\Subject\SubjectId; -use RuntimeException; +use DomainException; use function sprintf; -final class SubjectNonSupprimableException extends RuntimeException +final class SubjectNonSupprimableException extends DomainException { public static function avecNotes(SubjectId $id): self { diff --git a/backend/src/Administration/Domain/Exception/SubjectNotFoundException.php b/backend/src/Administration/Domain/Exception/SubjectNotFoundException.php index fbe1566..d1b1377 100644 --- a/backend/src/Administration/Domain/Exception/SubjectNotFoundException.php +++ b/backend/src/Administration/Domain/Exception/SubjectNotFoundException.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\Subject\SubjectId; -use RuntimeException; +use DomainException; use function sprintf; -final class SubjectNotFoundException extends RuntimeException +final class SubjectNotFoundException extends DomainException { public static function withId(SubjectId $id): self { diff --git a/backend/src/Administration/Domain/Exception/TokenAlreadyRotatedException.php b/backend/src/Administration/Domain/Exception/TokenAlreadyRotatedException.php index c425370..093bbeb 100644 --- a/backend/src/Administration/Domain/Exception/TokenAlreadyRotatedException.php +++ b/backend/src/Administration/Domain/Exception/TokenAlreadyRotatedException.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; -use RuntimeException; +use DomainException; /** * Exception thrown when a refresh token has already been rotated but is still in grace period. @@ -14,7 +14,7 @@ use RuntimeException; * * @see Story 1.4 - Connexion utilisateur */ -final class TokenAlreadyRotatedException extends RuntimeException +final class TokenAlreadyRotatedException extends DomainException { public function __construct() { diff --git a/backend/src/Administration/Domain/Exception/TokenConsumptionInProgressException.php b/backend/src/Administration/Domain/Exception/TokenConsumptionInProgressException.php index f6a7b27..d45879c 100644 --- a/backend/src/Administration/Domain/Exception/TokenConsumptionInProgressException.php +++ b/backend/src/Administration/Domain/Exception/TokenConsumptionInProgressException.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; -use RuntimeException; +use DomainException; use function sprintf; @@ -14,7 +14,7 @@ use function sprintf; * This indicates a concurrent request is processing the same token, * and the client should retry after a short delay. */ -final class TokenConsumptionInProgressException extends RuntimeException +final class TokenConsumptionInProgressException extends DomainException { public function __construct(string $tokenValue) { diff --git a/backend/src/Administration/Domain/Exception/TokenReplayDetectedException.php b/backend/src/Administration/Domain/Exception/TokenReplayDetectedException.php index eb3d0ee..b19c18a 100644 --- a/backend/src/Administration/Domain/Exception/TokenReplayDetectedException.php +++ b/backend/src/Administration/Domain/Exception/TokenReplayDetectedException.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\RefreshToken\TokenFamilyId; -use RuntimeException; +use DomainException; use function sprintf; @@ -21,7 +21,7 @@ use function sprintf; * - Un audit log doit être créé * - Une alerte de sécurité peut être envoyée */ -final class TokenReplayDetectedException extends RuntimeException +final class TokenReplayDetectedException extends DomainException { public function __construct( public readonly TokenFamilyId $familyId, diff --git a/backend/src/Administration/Domain/Exception/UserNotFoundException.php b/backend/src/Administration/Domain/Exception/UserNotFoundException.php index e0582e8..b385787 100644 --- a/backend/src/Administration/Domain/Exception/UserNotFoundException.php +++ b/backend/src/Administration/Domain/Exception/UserNotFoundException.php @@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\User\Email; use App\Administration\Domain\Model\User\UserId; -use RuntimeException; +use DomainException; use function sprintf; -final class UserNotFoundException extends RuntimeException +final class UserNotFoundException extends DomainException { public static function withId(UserId $userId): self { diff --git a/backend/src/Administration/Domain/Exception/UtilisateurDejaInviteException.php b/backend/src/Administration/Domain/Exception/UtilisateurDejaInviteException.php index 8bf3c3a..a61218b 100644 --- a/backend/src/Administration/Domain/Exception/UtilisateurDejaInviteException.php +++ b/backend/src/Administration/Domain/Exception/UtilisateurDejaInviteException.php @@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\User\StatutCompte; use App\Administration\Domain\Model\User\UserId; -use RuntimeException; +use DomainException; use function sprintf; -final class UtilisateurDejaInviteException extends RuntimeException +final class UtilisateurDejaInviteException extends DomainException { public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self { diff --git a/backend/src/Administration/Domain/Exception/UtilisateurNonBlocableException.php b/backend/src/Administration/Domain/Exception/UtilisateurNonBlocableException.php index 2e8fb38..bc15d5c 100644 --- a/backend/src/Administration/Domain/Exception/UtilisateurNonBlocableException.php +++ b/backend/src/Administration/Domain/Exception/UtilisateurNonBlocableException.php @@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\User\StatutCompte; use App\Administration\Domain\Model\User\UserId; -use RuntimeException; +use DomainException; use function sprintf; -final class UtilisateurNonBlocableException extends RuntimeException +final class UtilisateurNonBlocableException extends DomainException { public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self { diff --git a/backend/src/Administration/Domain/Exception/UtilisateurNonDeblocableException.php b/backend/src/Administration/Domain/Exception/UtilisateurNonDeblocableException.php index fd2332d..4c45b98 100644 --- a/backend/src/Administration/Domain/Exception/UtilisateurNonDeblocableException.php +++ b/backend/src/Administration/Domain/Exception/UtilisateurNonDeblocableException.php @@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception; use App\Administration\Domain\Model\User\StatutCompte; use App\Administration\Domain\Model\User\UserId; -use RuntimeException; +use DomainException; use function sprintf; -final class UtilisateurNonDeblocableException extends RuntimeException +final class UtilisateurNonDeblocableException extends DomainException { public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self { diff --git a/backend/src/Administration/Domain/Model/User/User.php b/backend/src/Administration/Domain/Model/User/User.php index da59bd1..6112da5 100644 --- a/backend/src/Administration/Domain/Model/User/User.php +++ b/backend/src/Administration/Domain/Model/User/User.php @@ -8,10 +8,15 @@ 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\RoleAttribue; +use App\Administration\Domain\Event\RoleRetire; 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\DernierRoleNonRetirableException; +use App\Administration\Domain\Exception\RoleDejaAttribueException; +use App\Administration\Domain\Exception\RoleNonAttribueException; use App\Administration\Domain\Exception\UtilisateurDejaInviteException; use App\Administration\Domain\Exception\UtilisateurNonBlocableException; use App\Administration\Domain\Exception\UtilisateurNonDeblocableException; @@ -19,12 +24,20 @@ use App\Administration\Domain\Model\ConsentementParental\ConsentementParental; use App\Administration\Domain\Policy\ConsentementParentalPolicy; use App\Shared\Domain\AggregateRoot; use App\Shared\Domain\Tenant\TenantId; + +use function array_values; +use function count; + use DateTimeImmutable; +use function in_array; + +use InvalidArgumentException; + /** * Aggregate Root representing a user in Classeo. * - * A user belongs to a school (tenant) and has a role. + * A user belongs to a school (tenant) and can have multiple roles (FR5). * The account lifecycle goes through multiple statuses: creation → activation. * Minors (< 15 years) require parental consent before activation. */ @@ -37,10 +50,16 @@ final class User extends AggregateRoot public private(set) ?DateTimeImmutable $blockedAt = null; public private(set) ?string $blockedReason = null; + /** @var Role[] */ + public private(set) array $roles; + + /** + * @param Role[] $roles + */ private function __construct( public private(set) UserId $id, public private(set) Email $email, - public private(set) Role $role, + array $roles, public private(set) TenantId $tenantId, public private(set) string $schoolName, public private(set) StatutCompte $statut, @@ -49,6 +68,14 @@ final class User extends AggregateRoot public private(set) string $firstName = '', public private(set) string $lastName = '', ) { + $this->roles = $roles; + } + + /** + * Returns the primary role (first assigned role) for backward compatibility. + */ + public Role $role { + get => $this->roles[0]; } /** @@ -65,7 +92,7 @@ final class User extends AggregateRoot $user = new self( id: UserId::generate(), email: $email, - role: $role, + roles: [$role], tenantId: $tenantId, schoolName: $schoolName, statut: StatutCompte::EN_ATTENTE, @@ -84,6 +111,73 @@ final class User extends AggregateRoot return $user; } + /** + * Assigns an additional role to the user. + * + * @throws RoleDejaAttribueException if the role is already assigned + */ + public function attribuerRole(Role $role, DateTimeImmutable $at): void + { + if ($this->aLeRole($role)) { + throw RoleDejaAttribueException::pour($this->id, $role); + } + + $this->roles = [...$this->roles, $role]; + + $this->recordEvent(new RoleAttribue( + userId: $this->id, + email: (string) $this->email, + role: $role->value, + tenantId: $this->tenantId, + occurredOn: $at, + )); + } + + /** + * Removes a role from the user. + * + * @throws RoleNonAttribueException if the role is not assigned + * @throws DernierRoleNonRetirableException if this is the last role + */ + public function retirerRole(Role $role, DateTimeImmutable $at): void + { + if (!$this->aLeRole($role)) { + throw RoleNonAttribueException::pour($this->id, $role); + } + + if (count($this->roles) === 1) { + throw DernierRoleNonRetirableException::pour($this->id); + } + + $this->roles = array_values( + array_filter($this->roles, static fn (Role $r) => $r !== $role), + ); + + $this->recordEvent(new RoleRetire( + userId: $this->id, + email: (string) $this->email, + role: $role->value, + tenantId: $this->tenantId, + occurredOn: $at, + )); + } + + /** + * Checks if the user has a specific role. + */ + public function aLeRole(Role $role): bool + { + return in_array($role, $this->roles, true); + } + + /** + * Returns the primary role (first assigned). + */ + public function rolePrincipal(): Role + { + return $this->roles[0]; + } + /** * Activates the account with the hashed password. * @@ -167,7 +261,7 @@ final class User extends AggregateRoot $user = new self( id: UserId::generate(), email: $email, - role: $role, + roles: [$role], tenantId: $tenantId, schoolName: $schoolName, statut: StatutCompte::EN_ATTENTE, @@ -296,12 +390,14 @@ final class User extends AggregateRoot /** * Reconstitutes a User from storage. * + * @param Role[] $roles + * * @internal For Infrastructure use only */ public static function reconstitute( UserId $id, Email $email, - Role $role, + array $roles, TenantId $tenantId, string $schoolName, StatutCompte $statut, @@ -316,10 +412,14 @@ final class User extends AggregateRoot ?DateTimeImmutable $blockedAt = null, ?string $blockedReason = null, ): self { + if ($roles === []) { + throw new InvalidArgumentException('Un utilisateur doit avoir au moins un rôle.'); + } + $user = new self( id: $id, email: $email, - role: $role, + roles: $roles, tenantId: $tenantId, schoolName: $schoolName, statut: $statut, diff --git a/backend/src/Administration/Infrastructure/Api/Controller/LogoutController.php b/backend/src/Administration/Infrastructure/Api/Controller/LogoutController.php index b16fedd..567799f 100644 --- a/backend/src/Administration/Infrastructure/Api/Controller/LogoutController.php +++ b/backend/src/Administration/Infrastructure/Api/Controller/LogoutController.php @@ -98,6 +98,17 @@ final readonly class LogoutController ->withSameSite($isSecure ? 'strict' : 'lax'), ); + // Clear session ID cookie (active role scoping) + $response->headers->setCookie( + Cookie::create('classeo_sid') + ->withValue('') + ->withExpires(new DateTimeImmutable('-1 hour')) + ->withPath('/api') + ->withHttpOnly(true) + ->withSecure($isSecure) + ->withSameSite($isSecure ? 'strict' : 'lax'), + ); + return $response; } } diff --git a/backend/src/Administration/Infrastructure/Api/Processor/InviteUserProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/InviteUserProcessor.php index 8074e15..cd991e2 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/InviteUserProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/InviteUserProcessor.php @@ -10,12 +10,18 @@ use App\Administration\Application\Command\InviteUser\InviteUserCommand; use App\Administration\Application\Command\InviteUser\InviteUserHandler; use App\Administration\Domain\Exception\EmailDejaUtiliseeException; use App\Administration\Domain\Exception\EmailInvalideException; +use App\Administration\Domain\Model\User\Role; use App\Administration\Infrastructure\Api\Resource\UserResource; +use App\Administration\Infrastructure\Security\SecurityUser; use App\Administration\Infrastructure\Security\UserVoter; use App\Shared\Domain\Clock; use App\Shared\Infrastructure\Tenant\TenantContext; + +use function in_array; + use InvalidArgumentException; use Override; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; @@ -34,6 +40,7 @@ final readonly class InviteUserProcessor implements ProcessorInterface private MessageBusInterface $eventBus, private AuthorizationCheckerInterface $authorizationChecker, private Clock $clock, + private Security $security, ) { } @@ -54,6 +61,19 @@ final readonly class InviteUserProcessor implements ProcessorInterface $tenantId = (string) $this->tenantContext->getCurrentTenantId(); $tenantConfig = $this->tenantContext->getCurrentTenantConfig(); + // Guard: prevent privilege escalation (only SUPER_ADMIN can assign SUPER_ADMIN) + /** @var string[] $requestedRoles */ + $requestedRoles = $data->roles ?? []; + $currentUser = $this->security->getUser(); + if ($currentUser instanceof SecurityUser) { + $currentRoles = $currentUser->getRoles(); + + if (!in_array(Role::SUPER_ADMIN->value, $currentRoles, true) + && in_array(Role::SUPER_ADMIN->value, $requestedRoles, true)) { + throw new AccessDeniedHttpException('Seul un super administrateur peut attribuer le rôle SUPER_ADMIN.'); + } + } + try { $command = new InviteUserCommand( tenantId: $tenantId, @@ -62,6 +82,7 @@ final readonly class InviteUserProcessor implements ProcessorInterface role: $data->role ?? '', firstName: $data->firstName ?? '', lastName: $data->lastName ?? '', + roles: $data->roles ?? [], ); $user = ($this->handler)($command); diff --git a/backend/src/Administration/Infrastructure/Api/Processor/SwitchRoleProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/SwitchRoleProcessor.php new file mode 100644 index 0000000..95ea2c2 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/SwitchRoleProcessor.php @@ -0,0 +1,70 @@ + + */ +final readonly class SwitchRoleProcessor implements ProcessorInterface +{ + public function __construct( + private Security $security, + private UserRepository $userRepository, + private RoleContext $roleContext, + ) { + } + + /** + * @param SwitchRoleInput $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchRoleOutput + { + $currentUser = $this->security->getUser(); + if (!$currentUser instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + $role = Role::tryFrom($data->role ?? ''); + if ($role === null) { + throw new BadRequestHttpException('Rôle invalide.'); + } + + try { + $user = $this->userRepository->get(UserId::fromString($currentUser->userId())); + $this->roleContext->switchTo($user, $role); + } catch (UserNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (RoleNonAttribueException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (DomainException $e) { + throw new AccessDeniedHttpException($e->getMessage()); + } + + return new SwitchRoleOutput( + activeRole: $role->value, + activeRoleLabel: $role->label(), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessor.php new file mode 100644 index 0000000..bbd2b24 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessor.php @@ -0,0 +1,114 @@ + + */ +final readonly class UpdateUserRolesProcessor implements ProcessorInterface +{ + public function __construct( + private UpdateUserRolesHandler $handler, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private TenantContext $tenantContext, + private Clock $clock, + private Security $security, + ) { + } + + /** + * @param UserResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UserResource + { + if (!$this->authorizationChecker->isGranted(UserVoter::MANAGE_ROLES)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier les rôles.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $userId */ + $userId = $uriVariables['id'] ?? ''; + + /** @var string[] $roles */ + $roles = $data->roles ?? []; + + // Guard: prevent privilege escalation (only SUPER_ADMIN can assign SUPER_ADMIN) + $currentUser = $this->security->getUser(); + if ($currentUser instanceof SecurityUser) { + $currentRoles = $currentUser->getRoles(); + + if (!in_array(Role::SUPER_ADMIN->value, $currentRoles, true) + && in_array(Role::SUPER_ADMIN->value, $roles, true)) { + throw new AccessDeniedHttpException('Seul un super administrateur peut attribuer le rôle SUPER_ADMIN.'); + } + + // Guard: prevent admin self-demotion + if ($currentUser->userId() === $userId) { + $isCurrentlyAdmin = in_array(Role::ADMIN->value, $currentRoles, true) + || in_array(Role::SUPER_ADMIN->value, $currentRoles, true); + $willRemainAdmin = in_array(Role::ADMIN->value, $roles, true) + || in_array(Role::SUPER_ADMIN->value, $roles, true); + + if ($isCurrentlyAdmin && !$willRemainAdmin) { + throw new BadRequestHttpException('Vous ne pouvez pas retirer votre propre rôle administrateur.'); + } + } + } + + try { + $command = new UpdateUserRolesCommand( + userId: $userId, + roles: $roles, + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + ); + $user = ($this->handler)($command); + + // Domain events are collected by the aggregate during handler execution, + // then pulled and dispatched here. The handler does not dispatch events — + // this is the single dispatch point, not a double dispatch. + 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 (InvalidArgumentException|RoleDejaAttribueException|RoleNonAttribueException|DernierRoleNonRetirableException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php new file mode 100644 index 0000000..5f91ea9 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php @@ -0,0 +1,62 @@ + + */ +final readonly class MyRolesProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private UserRepository $userRepository, + private RoleContext $roleContext, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): MyRolesOutput + { + $currentUser = $this->security->getUser(); + if (!$currentUser instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + try { + $user = $this->userRepository->get(UserId::fromString($currentUser->userId())); + } catch (UserNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } + + $activeRole = $this->roleContext->getActiveRole($user); + + $output = new MyRolesOutput(); + $output->roles = array_map( + static fn (Role $role) => ['value' => $role->value, 'label' => $role->label()], + $user->roles, + ); + $output->activeRole = $activeRole->value; + $output->activeRoleLabel = $activeRole->label(); + + return $output; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/MyRolesOutput.php b/backend/src/Administration/Infrastructure/Api/Resource/MyRolesOutput.php new file mode 100644 index 0000000..86bbb18 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/MyRolesOutput.php @@ -0,0 +1,33 @@ + */ + public array $roles = []; + + public string $activeRole = ''; + + public string $activeRoleLabel = ''; +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/SwitchRoleInput.php b/backend/src/Administration/Infrastructure/Api/Resource/SwitchRoleInput.php new file mode 100644 index 0000000..780c01d --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/SwitchRoleInput.php @@ -0,0 +1,27 @@ + ['Default', 'roles']], + name: 'update_user_roles', + ), ], )] final class UserResource @@ -76,6 +87,11 @@ final class UserResource public ?string $roleLabel = null; + /** @var string[]|null */ + #[Assert\NotBlank(message: 'Les rôles sont requis.', groups: ['roles'])] + #[Assert\Count(min: 1, minMessage: 'Au moins un rôle est requis.', groups: ['roles'])] + public ?array $roles = null; + #[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])] public ?string $firstName = null; @@ -107,6 +123,10 @@ final class UserResource $resource->email = (string) $user->email; $resource->role = $user->role->value; $resource->roleLabel = $user->role->label(); + $resource->roles = array_map( + static fn (Role $r) => $r->value, + $user->roles, + ); $resource->firstName = $user->firstName; $resource->lastName = $user->lastName; $resource->statut = $user->statut->value; @@ -127,6 +147,7 @@ final class UserResource $resource->email = $dto->email; $resource->role = $dto->role; $resource->roleLabel = $dto->roleLabel; + $resource->roles = $dto->roles; $resource->firstName = $dto->firstName; $resource->lastName = $dto->lastName; $resource->statut = $dto->statut; diff --git a/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php b/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php index 50bbfa1..0a0652c 100644 --- a/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php +++ b/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php @@ -131,7 +131,7 @@ final class CreateTestUserCommand extends Command $user = User::reconstitute( id: UserId::generate(), email: new Email($email), - role: $role, + roles: [$role], tenantId: $tenantId, schoolName: $schoolName, statut: StatutCompte::ACTIF, diff --git a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php index 6aaca11..e539e7f 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php @@ -18,6 +18,9 @@ use DateTimeImmutable; use function in_array; use Psr\Cache\CacheItemPoolInterface; +use RuntimeException; + +use function sprintf; /** * Cache-based UserRepository for development and testing. @@ -71,7 +74,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, 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 */ + /** @var array{id: string, email: string, roles?: 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); @@ -136,7 +139,7 @@ final readonly class CacheUserRepository implements UserRepository return [ 'id' => (string) $user->id, 'email' => (string) $user->email, - 'role' => $user->role->value, + 'roles' => array_map(static fn (Role $r) => $r->value, $user->roles), 'tenant_id' => (string) $user->tenantId, 'school_name' => $user->schoolName, 'statut' => $user->statut->value, @@ -162,7 +165,8 @@ final readonly class CacheUserRepository implements UserRepository * @param array{ * id: string, * email: string, - * role: string, + * roles?: string[], + * role?: string, * tenant_id: string, * school_name: string, * statut: string, @@ -194,10 +198,19 @@ 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; + // Support both legacy single role and new multi-role format + $roleStrings = $data['roles'] ?? (isset($data['role']) ? [$data['role']] : []); + + if ($roleStrings === []) { + throw new RuntimeException(sprintf('User %s has no roles in cache data.', $data['id'])); + } + + $roles = array_map(static fn (string $r) => Role::from($r), $roleStrings); + return User::reconstitute( id: UserId::fromString($data['id']), email: new Email($data['email']), - role: Role::from($data['role']), + roles: $roles, tenantId: TenantId::fromString($data['tenant_id']), schoolName: $data['school_name'], statut: StatutCompte::from($data['statut']), diff --git a/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php b/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php index fdd59ac..69dc0c4 100644 --- a/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php +++ b/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php @@ -16,6 +16,7 @@ use App\Shared\Domain\Clock; use App\Shared\Domain\Tenant\TenantId; use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface; use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent; +use Ramsey\Uuid\Uuid; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Messenger\MessageBusInterface; @@ -101,6 +102,16 @@ final readonly class LoginSuccessHandler $response->headers->setCookie($cookie); + // Session ID cookie for active role scoping (per-device isolation) + $sessionIdCookie = Cookie::create('classeo_sid') + ->withValue(Uuid::uuid4()->toString()) + ->withExpires($refreshToken->expiresAt) + ->withPath('/api') + ->withSecure($isSecure) + ->withHttpOnly(true) + ->withSameSite($isSecure ? 'strict' : 'lax'); + $response->headers->setCookie($sessionIdCookie); + // Reset the rate limiter for this email $this->rateLimiter->reset($email); diff --git a/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php b/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php index ed4a55e..a16dc93 100644 --- a/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php +++ b/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php @@ -23,12 +23,10 @@ final readonly class SecurityUserFactory email: (string) $domainUser->email, hashedPassword: $domainUser->hashedPassword ?? '', tenantId: $domainUser->tenantId, - roles: [$this->mapRoleToSymfony($domainUser->role)], + roles: array_values(array_map( + static fn (Role $role) => $role->value, + $domainUser->roles, + )), ); } - - private function mapRoleToSymfony(Role $role): string - { - return $role->value; - } } diff --git a/backend/src/Administration/Infrastructure/Security/UserVoter.php b/backend/src/Administration/Infrastructure/Security/UserVoter.php index 76608e7..f4bc6b3 100644 --- a/backend/src/Administration/Infrastructure/Security/UserVoter.php +++ b/backend/src/Administration/Infrastructure/Security/UserVoter.php @@ -30,6 +30,7 @@ final class UserVoter extends Voter public const string BLOCK = 'USER_BLOCK'; public const string UNBLOCK = 'USER_UNBLOCK'; public const string RESEND_INVITATION = 'USER_RESEND_INVITATION'; + public const string MANAGE_ROLES = 'USER_MANAGE_ROLES'; private const array SUPPORTED_ATTRIBUTES = [ self::VIEW, @@ -37,6 +38,7 @@ final class UserVoter extends Voter self::BLOCK, self::UNBLOCK, self::RESEND_INVITATION, + self::MANAGE_ROLES, ]; #[Override] @@ -66,7 +68,7 @@ final class UserVoter extends Voter return match ($attribute) { self::VIEW => $this->canView($roles), - self::CREATE, self::BLOCK, self::UNBLOCK, self::RESEND_INVITATION => $this->canManage($roles), + self::CREATE, self::BLOCK, self::UNBLOCK, self::RESEND_INVITATION, self::MANAGE_ROLES => $this->canManage($roles), default => false, }; } diff --git a/backend/src/Administration/Infrastructure/Service/CacheActiveRoleStore.php b/backend/src/Administration/Infrastructure/Service/CacheActiveRoleStore.php new file mode 100644 index 0000000..35df334 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Service/CacheActiveRoleStore.php @@ -0,0 +1,70 @@ +sessionsCache->getItem($this->cacheKey($user)); + $item->set($role->value); + $item->expiresAfter(self::TTL_SECONDS); + $this->sessionsCache->save($item); + } + + public function get(User $user): ?Role + { + $item = $this->sessionsCache->getItem($this->cacheKey($user)); + + if (!$item->isHit()) { + return null; + } + + $stored = $item->get(); + if (!is_string($stored)) { + return null; + } + + $role = Role::tryFrom($stored); + if ($role !== null && $user->aLeRole($role)) { + return $role; + } + + return null; + } + + public function clear(User $user): void + { + $this->sessionsCache->deleteItem($this->cacheKey($user)); + } + + private function cacheKey(User $user): string + { + $sessionId = $this->requestStack->getCurrentRequest()?->cookies->get('classeo_sid', '') ?? ''; + + return sprintf('%s%s:%s', self::CACHE_KEY_PREFIX, $user->id, $sessionId); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/AssignRoleHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/AssignRoleHandlerTest.php new file mode 100644 index 0000000..2f0a2e2 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/AssignRoleHandlerTest.php @@ -0,0 +1,116 @@ +userRepository = new InMemoryUserRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-08 10:00:00'); + } + }; + + $this->handler = new AssignRoleHandler($this->userRepository, $clock); + } + + #[Test] + public function itAssignsRoleToUser(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $result = ($this->handler)(new AssignRoleCommand( + userId: (string) $user->id, + role: Role::PARENT->value, + )); + + self::assertTrue($result->aLeRole(Role::PROF)); + self::assertTrue($result->aLeRole(Role::PARENT)); + } + + #[Test] + public function itRecordsRoleAttribueEvent(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + $user->pullDomainEvents(); + + ($this->handler)(new AssignRoleCommand( + userId: (string) $user->id, + role: Role::PARENT->value, + )); + + $savedUser = $this->userRepository->get($user->id); + $events = $savedUser->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(RoleAttribue::class, $events[0]); + } + + #[Test] + public function itThrowsWhenRoleAlreadyAssigned(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $this->expectException(RoleDejaAttribueException::class); + + ($this->handler)(new AssignRoleCommand( + userId: (string) $user->id, + role: Role::PROF->value, + )); + } + + #[Test] + public function itThrowsWhenRoleIsInvalid(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $this->expectException(InvalidArgumentException::class); + + ($this->handler)(new AssignRoleCommand( + userId: (string) $user->id, + role: 'ROLE_INVALID', + )); + } + + private function createUser(Role $role): User + { + return User::creer( + email: new Email('user@example.com'), + role: $role, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/RemoveRoleHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/RemoveRoleHandlerTest.php new file mode 100644 index 0000000..5662263 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/RemoveRoleHandlerTest.php @@ -0,0 +1,126 @@ +userRepository = new InMemoryUserRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-08 10:00:00'); + } + }; + + $this->handler = new RemoveRoleHandler($this->userRepository, $clock); + } + + #[Test] + public function itRemovesRoleFromUser(): void + { + $user = $this->createUserWithRoles(Role::PROF, Role::PARENT); + $this->userRepository->save($user); + + $result = ($this->handler)(new RemoveRoleCommand( + userId: (string) $user->id, + role: Role::PARENT->value, + )); + + self::assertTrue($result->aLeRole(Role::PROF)); + self::assertFalse($result->aLeRole(Role::PARENT)); + } + + #[Test] + public function itRecordsRoleRetireEvent(): void + { + $user = $this->createUserWithRoles(Role::PROF, Role::PARENT); + $this->userRepository->save($user); + $user->pullDomainEvents(); + + ($this->handler)(new RemoveRoleCommand( + userId: (string) $user->id, + role: Role::PARENT->value, + )); + + $savedUser = $this->userRepository->get($user->id); + $events = $savedUser->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(RoleRetire::class, $events[0]); + } + + #[Test] + public function itThrowsWhenRoleNotAssigned(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $this->expectException(RoleNonAttribueException::class); + + ($this->handler)(new RemoveRoleCommand( + userId: (string) $user->id, + role: Role::PARENT->value, + )); + } + + #[Test] + public function itThrowsWhenRemovingLastRole(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $this->expectException(DernierRoleNonRetirableException::class); + + ($this->handler)(new RemoveRoleCommand( + userId: (string) $user->id, + role: Role::PROF->value, + )); + } + + private function createUser(Role $role): User + { + return User::creer( + email: new Email('user@example.com'), + role: $role, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } + + private function createUserWithRoles(Role $role, Role ...$additionalRoles): User + { + $user = $this->createUser($role); + foreach ($additionalRoles as $r) { + $user->attribuerRole($r, new DateTimeImmutable()); + } + + return $user; + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UpdateUserRolesHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UpdateUserRolesHandlerTest.php new file mode 100644 index 0000000..c609626 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/UpdateUserRolesHandlerTest.php @@ -0,0 +1,119 @@ +userRepository = new InMemoryUserRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-08 10:00:00'); + } + }; + + $this->activeRoleStore = $this->createMock(ActiveRoleStore::class); + + $this->handler = new UpdateUserRolesHandler($this->userRepository, $clock, $this->activeRoleStore); + } + + #[Test] + public function itUpdatesRolesBulk(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $result = ($this->handler)(new UpdateUserRolesCommand( + userId: (string) $user->id, + roles: [Role::PARENT->value, Role::VIE_SCOLAIRE->value], + )); + + self::assertFalse($result->aLeRole(Role::PROF)); + self::assertTrue($result->aLeRole(Role::PARENT)); + self::assertTrue($result->aLeRole(Role::VIE_SCOLAIRE)); + } + + #[Test] + public function itKeepsExistingRolesIfInTargetList(): void + { + $user = $this->createUser(Role::PROF); + $user->attribuerRole(Role::PARENT, new DateTimeImmutable()); + $this->userRepository->save($user); + + $result = ($this->handler)(new UpdateUserRolesCommand( + userId: (string) $user->id, + roles: [Role::PROF->value, Role::PARENT->value, Role::ADMIN->value], + )); + + self::assertTrue($result->aLeRole(Role::PROF)); + self::assertTrue($result->aLeRole(Role::PARENT)); + self::assertTrue($result->aLeRole(Role::ADMIN)); + self::assertCount(3, $result->roles); + } + + #[Test] + public function itThrowsWhenEmptyRoles(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Au moins un rôle est requis.'); + + ($this->handler)(new UpdateUserRolesCommand( + userId: (string) $user->id, + roles: [], + )); + } + + #[Test] + public function itThrowsWhenInvalidRole(): void + { + $user = $this->createUser(Role::PROF); + $this->userRepository->save($user); + + $this->expectException(InvalidArgumentException::class); + + ($this->handler)(new UpdateUserRolesCommand( + userId: (string) $user->id, + roles: ['ROLE_INVALID'], + )); + } + + private function createUser(Role $role): User + { + return User::creer( + email: new Email('user@example.com'), + role: $role, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Alpha', + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/RoleContextTest.php b/backend/tests/Unit/Administration/Application/Service/RoleContextTest.php new file mode 100644 index 0000000..e6ae395 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/RoleContextTest.php @@ -0,0 +1,139 @@ +store = new InMemoryActiveRoleStore(); + $this->roleContext = new RoleContext($this->store); + } + + #[Test] + public function itReturnsPrimaryRoleWhenNoActiveRoleStored(): void + { + $user = $this->createUser(Role::PROF); + + self::assertSame(Role::PROF, $this->roleContext->getActiveRole($user)); + } + + #[Test] + public function itSwitchesToAnotherRole(): void + { + $user = $this->createActiveUser(Role::PROF); + $user->attribuerRole(Role::ADMIN, new DateTimeImmutable()); + + $this->roleContext->switchTo($user, Role::ADMIN); + + self::assertSame(Role::ADMIN, $this->roleContext->getActiveRole($user)); + } + + #[Test] + public function itThrowsWhenSwitchingToUnassignedRole(): void + { + $user = $this->createActiveUser(Role::PROF); + + $this->expectException(RoleNonAttribueException::class); + + $this->roleContext->switchTo($user, Role::ADMIN); + } + + #[Test] + public function itClearsActiveRole(): void + { + $user = $this->createActiveUser(Role::PROF); + $user->attribuerRole(Role::ADMIN, new DateTimeImmutable()); + + $this->roleContext->switchTo($user, Role::ADMIN); + $this->roleContext->clear($user); + + self::assertSame(Role::PROF, $this->roleContext->getActiveRole($user)); + } + + #[Test] + public function itThrowsWhenAccountIsNotActive(): void + { + $user = $this->createActiveUser(Role::PROF); + $user->attribuerRole(Role::ADMIN, new DateTimeImmutable()); + $user->bloquer('Test', new DateTimeImmutable()); + + $this->expectException(DomainException::class); + + $this->roleContext->switchTo($user, Role::ADMIN); + } + + private function createUser(Role $role): User + { + return User::creer( + email: new Email('user@example.com'), + role: $role, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } + + private function createActiveUser(Role $role): User + { + return User::reconstitute( + id: UserId::generate(), + email: new Email('active@example.com'), + roles: [$role], + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + statut: StatutCompte::ACTIF, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + hashedPassword: 'hashed', + activatedAt: new DateTimeImmutable('2026-01-16 10:00:00'), + consentementParental: null, + ); + } +} + +/** + * @internal + */ +final class InMemoryActiveRoleStore implements ActiveRoleStore +{ + /** @var array */ + private array $roles = []; + + public function store(User $user, Role $role): void + { + $this->roles[(string) $user->id] = $role; + } + + public function get(User $user): ?Role + { + return $this->roles[(string) $user->id] ?? null; + } + + public function clear(User $user): void + { + unset($this->roles[(string) $user->id]); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/User/UserInvitationTest.php b/backend/tests/Unit/Administration/Domain/Model/User/UserInvitationTest.php index f504454..999177e 100644 --- a/backend/tests/Unit/Administration/Domain/Model/User/UserInvitationTest.php +++ b/backend/tests/Unit/Administration/Domain/Model/User/UserInvitationTest.php @@ -176,7 +176,7 @@ final class UserInvitationTest extends TestCase $user = User::reconstitute( id: UserId::generate(), email: new Email('minor@example.com'), - role: Role::ELEVE, + roles: [Role::ELEVE], tenantId: TenantId::fromString(self::TENANT_ID), schoolName: self::SCHOOL_NAME, statut: StatutCompte::CONSENTEMENT_REQUIS, diff --git a/backend/tests/Unit/Administration/Domain/Model/User/UserRoleTest.php b/backend/tests/Unit/Administration/Domain/Model/User/UserRoleTest.php new file mode 100644 index 0000000..3864d54 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/User/UserRoleTest.php @@ -0,0 +1,156 @@ +createUser(Role::PROF); + + self::assertSame([Role::PROF], $user->roles); + } + + #[Test] + public function attribuerRoleAddsRoleToUser(): void + { + $user = $this->createUser(Role::PROF); + $user->pullDomainEvents(); + + $user->attribuerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00')); + + self::assertContains(Role::PROF, $user->roles); + self::assertContains(Role::PARENT, $user->roles); + self::assertCount(2, $user->roles); + } + + #[Test] + public function attribuerRoleRecordsRoleAttribueEvent(): void + { + $user = $this->createUser(Role::PROF); + $user->pullDomainEvents(); + + $user->attribuerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00')); + + $events = $user->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(RoleAttribue::class, $events[0]); + } + + #[Test] + public function attribuerRoleThrowsWhenRoleAlreadyAssigned(): void + { + $user = $this->createUser(Role::PROF); + + $this->expectException(RoleDejaAttribueException::class); + + $user->attribuerRole(Role::PROF, new DateTimeImmutable()); + } + + #[Test] + public function retirerRoleRemovesRoleFromUser(): void + { + $user = $this->createUser(Role::PROF); + $user->attribuerRole(Role::PARENT, new DateTimeImmutable()); + $user->pullDomainEvents(); + + $user->retirerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00')); + + self::assertSame([Role::PROF], $user->roles); + } + + #[Test] + public function retirerRoleRecordsRoleRetireEvent(): void + { + $user = $this->createUser(Role::PROF); + $user->attribuerRole(Role::PARENT, new DateTimeImmutable()); + $user->pullDomainEvents(); + + $user->retirerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00')); + + $events = $user->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(RoleRetire::class, $events[0]); + } + + #[Test] + public function retirerRoleThrowsWhenRoleNotAssigned(): void + { + $user = $this->createUser(Role::PROF); + + $this->expectException(RoleNonAttribueException::class); + + $user->retirerRole(Role::PARENT, new DateTimeImmutable()); + } + + #[Test] + public function retirerRoleThrowsWhenLastRole(): void + { + $user = $this->createUser(Role::PROF); + + $this->expectException(DernierRoleNonRetirableException::class); + + $user->retirerRole(Role::PROF, new DateTimeImmutable()); + } + + #[Test] + public function userCanHaveMultipleRoles(): void + { + $user = $this->createUser(Role::PROF); + $user->attribuerRole(Role::PARENT, new DateTimeImmutable()); + $user->attribuerRole(Role::VIE_SCOLAIRE, new DateTimeImmutable()); + + self::assertCount(3, $user->roles); + self::assertContains(Role::PROF, $user->roles); + self::assertContains(Role::PARENT, $user->roles); + self::assertContains(Role::VIE_SCOLAIRE, $user->roles); + } + + #[Test] + public function aLeRoleReturnsTrueForAssignedRole(): void + { + $user = $this->createUser(Role::PROF); + + self::assertTrue($user->aLeRole(Role::PROF)); + self::assertFalse($user->aLeRole(Role::PARENT)); + } + + #[Test] + public function rolePrincipalReturnsFirstRole(): void + { + $user = $this->createUser(Role::PROF); + + self::assertSame(Role::PROF, $user->rolePrincipal()); + } + + private function createUser(Role $role = Role::PARENT): User + { + return User::creer( + email: new Email('user@example.com'), + role: $role, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php index 37b3508..5dfa314 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php @@ -200,10 +200,13 @@ final class LogoutControllerTest extends TestCase // THEN: Cookies are cleared (expired) $cookies = $response->headers->getCookies(); - $this->assertCount(2, $cookies); // /api and /api/token (legacy) + $this->assertCount(3, $cookies); // refresh_token /api, /api/token (legacy), classeo_sid + + $cookieNames = array_map(static fn ($c) => $c->getName(), $cookies); + $this->assertContains('refresh_token', $cookieNames); + $this->assertContains('classeo_sid', $cookieNames); foreach ($cookies as $cookie) { - $this->assertSame('refresh_token', $cookie->getName()); $this->assertSame('', $cookie->getValue()); $this->assertTrue($cookie->isCleared()); // Expiry in the past } diff --git a/backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php b/backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php index d6223b4..5157bdd 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php @@ -185,7 +185,7 @@ final class CreateTestActivationTokenCommandTest extends TestCase return User::reconstitute( id: UserId::generate(), email: new Email($email), - role: Role::PARENT, + roles: [Role::PARENT], tenantId: TenantId::fromString(self::TENANT_ID), schoolName: 'École Test', statut: StatutCompte::ACTIF, diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php index ef3dc4d..ae390c5 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php @@ -226,7 +226,7 @@ final class DatabaseUserProviderTest extends TestCase return User::reconstitute( id: UserId::generate(), email: new Email('user@example.com'), - role: Role::PARENT, + roles: [Role::PARENT], tenantId: $tenantId, schoolName: 'École Test', statut: $statut, diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php index 29dead9..66c1235 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php @@ -133,12 +133,12 @@ final class LoginSuccessHandlerTest extends TestCase // WHEN: Handler processes the event $this->handler->onAuthenticationSuccess($event); - // THEN: Refresh token cookie is set + // THEN: Refresh token cookie and session ID cookie are set $cookies = $response->headers->getCookies(); - $this->assertCount(1, $cookies); - $this->assertSame('refresh_token', $cookies[0]->getName()); - $this->assertTrue($cookies[0]->isHttpOnly()); - $this->assertSame('/api', $cookies[0]->getPath()); + $this->assertCount(2, $cookies); + $cookieNames = array_map(static fn ($c) => $c->getName(), $cookies); + $this->assertContains('refresh_token', $cookieNames); + $this->assertContains('classeo_sid', $cookieNames); // THEN: Refresh token is saved in repository $this->assertTrue( @@ -304,10 +304,12 @@ final class LoginSuccessHandlerTest extends TestCase // WHEN: Handler processes the event $this->handler->onAuthenticationSuccess($event); - // THEN: Cookie is NOT marked as secure (HTTP) + // THEN: Cookies are NOT marked as secure (HTTP) $cookies = $response->headers->getCookies(); - $this->assertCount(1, $cookies); - $this->assertFalse($cookies[0]->isSecure()); + $this->assertCount(2, $cookies); + foreach ($cookies as $cookie) { + $this->assertFalse($cookie->isSecure()); + } } private function createRequest(): Request diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/SecurityUserTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/SecurityUserTest.php index 9eb1a80..3965624 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Security/SecurityUserTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Security/SecurityUserTest.php @@ -84,7 +84,7 @@ final class SecurityUserTest extends TestCase return User::reconstitute( id: UserId::generate(), email: new Email('user@example.com'), - role: $role, + roles: [$role], tenantId: TenantId::fromString(self::TENANT_ID), schoolName: 'École Test', statut: StatutCompte::ACTIF, diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/UserVoterTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/UserVoterTest.php index 3e2ff9c..d05106b 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Security/UserVoterTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Security/UserVoterTest.php @@ -121,6 +121,48 @@ final class UserVoterTest extends TestCase self::assertSame(UserVoter::ACCESS_DENIED, $result); } + #[Test] + public function itGrantsManageRolesToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', UserVoter::MANAGE_ROLES); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsManageRolesToSuperAdmin(): void + { + $result = $this->voteWithRole('ROLE_SUPER_ADMIN', UserVoter::MANAGE_ROLES); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesManageRolesToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', UserVoter::MANAGE_ROLES); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesManageRolesToSecretariat(): void + { + $result = $this->voteWithRole('ROLE_SECRETARIAT', UserVoter::MANAGE_ROLES); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsResendInvitationToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', UserVoter::RESEND_INVITATION); + self::assertSame(UserVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesResendInvitationToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', UserVoter::RESEND_INVITATION); + self::assertSame(UserVoter::ACCESS_DENIED, $result); + } + private function voteWithRole(string $role, string $attribute): int { $user = $this->createMock(UserInterface::class); diff --git a/frontend/e2e/pedagogy.spec.ts b/frontend/e2e/pedagogy.spec.ts index 3e8105f..a50066a 100644 --- a/frontend/e2e/pedagogy.spec.ts +++ b/frontend/e2e/pedagogy.spec.ts @@ -69,16 +69,11 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => { await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 }); }); - test('pedagogy card is visible on admin dashboard', async ({ page, browserName }) => { - // Svelte 5 delegated onclick is not triggered by Playwright click on webkit - test.skip(browserName === 'webkit', 'Demo role switcher click not supported on webkit'); - + test('pedagogy card is visible on admin dashboard', async ({ page }) => { await loginAsAdmin(page); - // Switch to admin view in demo dashboard + // Authenticated admin sees admin dashboard directly via role context await page.goto(`${ALPHA_URL}/dashboard`); - const adminButton = page.getByRole('button', { name: /admin/i }); - await adminButton.click(); await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 diff --git a/frontend/src/lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte b/frontend/src/lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte new file mode 100644 index 0000000..bd9a19b --- /dev/null +++ b/frontend/src/lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte @@ -0,0 +1,113 @@ + + +{#if hasMultipleRoles()} +
+ + + {#if getIsSwitching()} + + {/if} + {#if switchError} + {switchError} + {/if} +
+{/if} + + diff --git a/frontend/src/lib/features/roles/api/roles.ts b/frontend/src/lib/features/roles/api/roles.ts new file mode 100644 index 0000000..99ed208 --- /dev/null +++ b/frontend/src/lib/features/roles/api/roles.ts @@ -0,0 +1,86 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +/** + * Types pour la gestion des rôles utilisateur. + * + * @see Story 2.6 - Attribution des rôles + */ +export interface RoleInfo { + value: string; + label: string; +} + +export interface MyRolesResponse { + roles: RoleInfo[]; + activeRole: string; + activeRoleLabel: string; +} + +export interface SwitchRoleResponse { + activeRole: string; + activeRoleLabel: string; +} + +/** + * Récupère les rôles de l'utilisateur courant et le rôle actif. + */ +export async function getMyRoles(): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/roles`); + + if (!response.ok) { + throw new Error('Failed to fetch roles'); + } + + return await response.json(); +} + +/** + * Bascule le contexte vers un autre rôle. + */ +export async function switchRole(role: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/switch-role`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ role }) + }); + + if (!response.ok) { + throw new Error('Failed to switch role'); + } + + return await response.json(); +} + +/** + * Met à jour les rôles d'un utilisateur (admin uniquement). + */ +export async function updateUserRoles(userId: string, roles: string[]): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/users/${userId}/roles`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ roles }) + }); + + if (!response.ok) { + let errorMessage = `Erreur lors de la mise à jour des rôles (${response.status})`; + try { + const errorData = await response.json(); + if (errorData['hydra:description']) { + errorMessage = errorData['hydra:description']; + } else if (errorData.detail) { + errorMessage = errorData.detail; + } + } catch { + // JSON parsing failed, keep default message + } + throw new Error(errorMessage); + } +} diff --git a/frontend/src/lib/features/roles/roleContext.svelte.ts b/frontend/src/lib/features/roles/roleContext.svelte.ts new file mode 100644 index 0000000..9e29d08 --- /dev/null +++ b/frontend/src/lib/features/roles/roleContext.svelte.ts @@ -0,0 +1,111 @@ +import { getMyRoles, switchRole as apiSwitchRole, type RoleInfo } from './api/roles'; + +/** + * Contexte de rôle réactif. + * + * Gère le rôle actif de l'utilisateur lorsqu'il possède plusieurs rôles (FR5). + * Le rôle actif détermine quelle vue (dashboard, navigation) est affichée. + * + * @see Story 2.6 - Attribution des rôles + */ + +// État réactif +let roles = $state([]); +let activeRole = $state(null); +let activeRoleLabel = $state(null); +let isLoading = $state(false); +let isSwitching = $state(false); +let isFetched = $state(false); + +/** + * Charge les rôles de l'utilisateur courant depuis l'API. + * Protégé contre les appels multiples (guard isFetched). + */ +export async function fetchRoles(): Promise { + if (isFetched || isLoading) return; + + isLoading = true; + try { + const data = await getMyRoles(); + roles = data.roles; + activeRole = data.activeRole; + activeRoleLabel = data.activeRoleLabel; + isFetched = true; + } catch (error) { + console.error('[roleContext] Failed to fetch roles:', error); + } finally { + isLoading = false; + } +} + +/** + * Bascule vers un autre rôle. + */ +export async function switchTo(role: string): Promise { + if (role === activeRole) return true; + + isSwitching = true; + try { + const data = await apiSwitchRole(role); + activeRole = data.activeRole; + activeRoleLabel = data.activeRoleLabel; + return true; + } catch (error) { + console.error('[roleContext] Failed to switch role:', error); + return false; + } finally { + isSwitching = false; + } +} + +/** + * Indique si l'utilisateur a plusieurs rôles (et donc peut basculer). + */ +export function hasMultipleRoles(): boolean { + return roles.length > 1; +} + +/** + * Retourne les rôles disponibles. + */ +export function getRoles(): RoleInfo[] { + return roles; +} + +/** + * Retourne le rôle actif. + */ +export function getActiveRole(): string | null { + return activeRole; +} + +/** + * Retourne le libellé du rôle actif. + */ +export function getActiveRoleLabel(): string | null { + return activeRoleLabel; +} + +/** + * Indique si le chargement initial est en cours. + */ +export function getIsLoading(): boolean { + return isLoading; +} + +/** + * Indique si un basculement de rôle est en cours. + */ +export function getIsSwitching(): boolean { + return isSwitching; +} + +/** + * Réinitialise l'état (à appeler au logout). + */ +export function resetRoleContext(): void { + roles = []; + activeRole = null; + activeRoleLabel = null; + isFetched = false; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4725f6b..54f3ca7 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { browser } from '$app/environment'; import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; import { onLogout } from '$lib/auth/auth.svelte'; + import { resetRoleContext } from '$features/roles/roleContext.svelte'; let { children } = $props(); @@ -21,6 +22,7 @@ // Clear user-specific caches on logout to prevent cross-account data leakage onLogout(() => { queryClient.removeQueries({ queryKey: ['sessions'] }); + resetRoleContext(); }); diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 22501cb..fb768e1 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -1,14 +1,23 @@ @@ -44,17 +62,25 @@ Tableau de bord - Classeo - - -
- Démo - Changer de rôle : - - - - -
+ +{#if isRoleLoading} +
+
+

Chargement du tableau de bord...

+
+{:else if !hasRoleContext && !isRoleLoading} + + +
+ Démo - Changer de rôle : + + + + +
+{/if} -{#if userRole === 'parent'} +{#if dashboardView === 'parent'} -{:else if userRole === 'teacher'} +{:else if dashboardView === 'teacher'} -{:else if userRole === 'student'} +{:else if dashboardView === 'student'} -{:else if userRole === 'admin' || userRole === 'direction'} +{:else if dashboardView === 'admin'} + .loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + } + + .spinner { + width: 2rem; + height: 2rem; + border: 3px solid #e5e7eb; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + .demo-controls { display: flex; align-items: center;