feat: Attribution de rôles multiples par utilisateur
Les utilisateurs Classeo étaient limités à un seul rôle, alors que dans la réalité scolaire un directeur peut aussi être enseignant, ou un parent peut avoir un rôle vie scolaire. Cette limitation obligeait à créer des comptes distincts par fonction. Le modèle User supporte désormais plusieurs rôles simultanés avec basculement via le header. L'admin peut attribuer/retirer des rôles depuis l'interface de gestion, avec des garde-fous : pas d'auto- destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut attribuer SUPER_ADMIN), vérification du statut actif pour le switch de rôle, et TTL explicite sur le cache de rôle actif.
This commit is contained in:
@@ -1,4 +1,14 @@
|
|||||||
security:
|
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
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ services:
|
|||||||
App\Administration\Application\Port\GradeExistenceChecker:
|
App\Administration\Application\Port\GradeExistenceChecker:
|
||||||
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
|
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)
|
# GeoLocation Service (null implementation - no geolocation)
|
||||||
App\Administration\Application\Port\GeoLocationService:
|
App\Administration\Application\Port\GeoLocationService:
|
||||||
alias: App\Administration\Infrastructure\Service\NullGeoLocationService
|
alias: App\Administration\Infrastructure\Service\NullGeoLocationService
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\AssignRole;
|
||||||
|
|
||||||
|
final readonly class AssignRoleCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $userId,
|
||||||
|
public string $role,
|
||||||
|
public string $tenantId = '',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\AssignRole;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class AssignRoleHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(AssignRoleCommand $command): User
|
||||||
|
{
|
||||||
|
$userId = UserId::fromString($command->userId);
|
||||||
|
$user = $this->userRepository->get($userId);
|
||||||
|
|
||||||
|
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
|
||||||
|
throw UserNotFoundException::withId($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = Role::tryFrom($command->role);
|
||||||
|
if ($role === null) {
|
||||||
|
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->attribuerRole($role, $this->clock->now());
|
||||||
|
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Application\Command\InviteUser;
|
namespace App\Administration\Application\Command\InviteUser;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
final readonly class InviteUserCommand
|
final readonly class InviteUserCommand
|
||||||
{
|
{
|
||||||
|
/** @var string[] */
|
||||||
|
public array $roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $tenantId,
|
public string $tenantId,
|
||||||
public string $schoolName,
|
public string $schoolName,
|
||||||
@@ -14,6 +24,16 @@ final readonly class InviteUserCommand
|
|||||||
public string $firstName,
|
public string $firstName,
|
||||||
public string $lastName,
|
public string $lastName,
|
||||||
public ?string $dateNaissance = null,
|
public ?string $dateNaissance = null,
|
||||||
|
array $roles = [],
|
||||||
) {
|
) {
|
||||||
|
$resolved = $roles !== [] ? $roles : [$role];
|
||||||
|
|
||||||
|
foreach ($resolved as $r) {
|
||||||
|
if (!is_string($r)) {
|
||||||
|
throw new InvalidArgumentException('Chaque rôle doit être une chaîne de caractères.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->roles = $resolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ use App\Administration\Domain\Model\User\User;
|
|||||||
use App\Administration\Domain\Repository\UserRepository;
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
use function array_slice;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
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 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
|
public function __invoke(InviteUserCommand $command): User
|
||||||
{
|
{
|
||||||
$tenantId = TenantId::fromString($command->tenantId);
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
$email = new Email($command->email);
|
$email = new Email($command->email);
|
||||||
|
|
||||||
$role = Role::tryFrom($command->role);
|
$roles = array_map(static function (string $r): Role {
|
||||||
if ($role === null) {
|
$role = Role::tryFrom($r);
|
||||||
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
|
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);
|
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
|
||||||
@@ -43,19 +55,25 @@ final readonly class InviteUserHandler
|
|||||||
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
|
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
$user = User::inviter(
|
$user = User::inviter(
|
||||||
email: $email,
|
email: $email,
|
||||||
role: $role,
|
role: $roles[0],
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
schoolName: $command->schoolName,
|
schoolName: $command->schoolName,
|
||||||
firstName: $command->firstName,
|
firstName: $command->firstName,
|
||||||
lastName: $command->lastName,
|
lastName: $command->lastName,
|
||||||
invitedAt: $this->clock->now(),
|
invitedAt: $now,
|
||||||
dateNaissance: $command->dateNaissance !== null
|
dateNaissance: $command->dateNaissance !== null
|
||||||
? new DateTimeImmutable($command->dateNaissance)
|
? new DateTimeImmutable($command->dateNaissance)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
foreach (array_slice($roles, 1) as $additionalRole) {
|
||||||
|
$user->attribuerRole($additionalRole, $now);
|
||||||
|
}
|
||||||
|
|
||||||
$this->userRepository->save($user);
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\RemoveRole;
|
||||||
|
|
||||||
|
final readonly class RemoveRoleCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $userId,
|
||||||
|
public string $role,
|
||||||
|
public string $tenantId = '',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\RemoveRole;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class RemoveRoleHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(RemoveRoleCommand $command): User
|
||||||
|
{
|
||||||
|
$userId = UserId::fromString($command->userId);
|
||||||
|
$user = $this->userRepository->get($userId);
|
||||||
|
|
||||||
|
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
|
||||||
|
throw UserNotFoundException::withId($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = Role::tryFrom($command->role);
|
||||||
|
if ($role === null) {
|
||||||
|
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->retirerRole($role, $this->clock->now());
|
||||||
|
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\UpdateUserRoles;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
final readonly class UpdateUserRolesCommand
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $userId,
|
||||||
|
public array $roles,
|
||||||
|
public string $tenantId = '',
|
||||||
|
) {
|
||||||
|
foreach ($roles as $r) {
|
||||||
|
if (!is_string($r)) {
|
||||||
|
throw new InvalidArgumentException('Chaque rôle doit être une chaîne de caractères.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\UpdateUserRoles;
|
||||||
|
|
||||||
|
use App\Administration\Application\Port\ActiveRoleStore;
|
||||||
|
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
|
use function array_map;
|
||||||
|
use function array_values;
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class UpdateUserRolesHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
private ActiveRoleStore $activeRoleStore,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(UpdateUserRolesCommand $command): User
|
||||||
|
{
|
||||||
|
$userId = UserId::fromString($command->userId);
|
||||||
|
$user = $this->userRepository->get($userId);
|
||||||
|
|
||||||
|
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
|
||||||
|
throw UserNotFoundException::withId($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($command->roles === []) {
|
||||||
|
throw new InvalidArgumentException('Au moins un rôle est requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetRoles = array_map(static function (string $r): Role {
|
||||||
|
$role = Role::tryFrom($r);
|
||||||
|
if ($role === null) {
|
||||||
|
throw new InvalidArgumentException("Rôle invalide : \"{$r}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $role;
|
||||||
|
}, $command->roles);
|
||||||
|
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
|
// Add new roles first to avoid "last role" exception
|
||||||
|
foreach ($targetRoles as $targetRole) {
|
||||||
|
if (!$user->aLeRole($targetRole)) {
|
||||||
|
$user->attribuerRole($targetRole, $now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect roles to remove, then remove them (avoid mutating during iteration)
|
||||||
|
/** @var list<Role> $rolesToRemove */
|
||||||
|
$rolesToRemove = array_values(array_filter(
|
||||||
|
$user->roles,
|
||||||
|
static fn (Role $currentRole) => !in_array($currentRole, $targetRoles, true),
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($rolesToRemove as $roleToRemove) {
|
||||||
|
$user->retirerRole($roleToRemove, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
|
// Clear cached active role to avoid stale reference to a removed role
|
||||||
|
$this->activeRoleStore->clear($user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port interface for storing the user's currently active role.
|
||||||
|
*
|
||||||
|
* When a user has multiple roles (FR5), they can switch between them.
|
||||||
|
* This store persists the selected role across requests.
|
||||||
|
*/
|
||||||
|
interface ActiveRoleStore
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Stores the active role for the given user.
|
||||||
|
*/
|
||||||
|
public function store(User $user, Role $role): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored active role for the given user, or null if none.
|
||||||
|
*/
|
||||||
|
public function get(User $user): ?Role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the stored active role for the given user (e.g. at logout).
|
||||||
|
*/
|
||||||
|
public function clear(User $user): void;
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ final readonly class GetUsersHandler
|
|||||||
if ($filterRole !== null) {
|
if ($filterRole !== null) {
|
||||||
$users = array_filter(
|
$users = array_filter(
|
||||||
$users,
|
$users,
|
||||||
static fn ($user) => $user->role === $filterRole,
|
static fn ($user) => $user->aLeRole($filterRole),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,23 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Application\Query\GetUsers;
|
namespace App\Administration\Application\Query\GetUsers;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
use App\Administration\Domain\Model\User\User;
|
use App\Administration\Domain\Model\User\User;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
final readonly class UserDto
|
final readonly class UserDto
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $id,
|
public string $id,
|
||||||
public string $email,
|
public string $email,
|
||||||
public string $role,
|
public string $role,
|
||||||
public string $roleLabel,
|
public string $roleLabel,
|
||||||
|
/** @var string[] */
|
||||||
|
public array $roles,
|
||||||
public string $firstName,
|
public string $firstName,
|
||||||
public string $lastName,
|
public string $lastName,
|
||||||
public string $statut,
|
public string $statut,
|
||||||
@@ -34,6 +40,7 @@ final readonly class UserDto
|
|||||||
email: (string) $user->email,
|
email: (string) $user->email,
|
||||||
role: $user->role->value,
|
role: $user->role->value,
|
||||||
roleLabel: $user->role->label(),
|
roleLabel: $user->role->label(),
|
||||||
|
roles: array_map(static fn (Role $r) => $r->value, $user->roles),
|
||||||
firstName: $user->firstName,
|
firstName: $user->firstName,
|
||||||
lastName: $user->lastName,
|
lastName: $user->lastName,
|
||||||
statut: $user->statut->value,
|
statut: $user->statut->value,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service;
|
||||||
|
|
||||||
|
use App\Administration\Application\Port\ActiveRoleStore;
|
||||||
|
use App\Administration\Domain\Exception\RoleNonAttribueException;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
final class RoleContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActiveRoleStore $activeRoleStore,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function switchTo(User $user, Role $role): void
|
||||||
|
{
|
||||||
|
if (!$user->peutSeConnecter()) {
|
||||||
|
throw new DomainException('Le compte n\'est pas actif.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->aLeRole($role)) {
|
||||||
|
throw RoleNonAttribueException::pour($user->id, $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->activeRoleStore->store($user, $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveRole(User $user): Role
|
||||||
|
{
|
||||||
|
$stored = $this->activeRoleStore->get($user);
|
||||||
|
|
||||||
|
if ($stored !== null) {
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->rolePrincipal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(User $user): void
|
||||||
|
{
|
||||||
|
$this->activeRoleStore->clear($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/src/Administration/Domain/Event/RoleAttribue.php
Normal file
36
backend/src/Administration/Domain/Event/RoleAttribue.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class RoleAttribue implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public UserId $userId,
|
||||||
|
public string $email,
|
||||||
|
public string $role,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->userId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/src/Administration/Domain/Event/RoleRetire.php
Normal file
36
backend/src/Administration/Domain/Event/RoleRetire.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class RoleRetire implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public UserId $userId,
|
||||||
|
public string $email,
|
||||||
|
public string $role,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->userId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class ActivationTokenAlreadyUsedException extends RuntimeException
|
final class ActivationTokenAlreadyUsedException extends DomainException
|
||||||
{
|
{
|
||||||
public static function forToken(ActivationTokenId $tokenId): self
|
public static function forToken(ActivationTokenId $tokenId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class ActivationTokenExpiredException extends RuntimeException
|
final class ActivationTokenExpiredException extends DomainException
|
||||||
{
|
{
|
||||||
public static function forToken(ActivationTokenId $tokenId): self
|
public static function forToken(ActivationTokenId $tokenId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class ActivationTokenNotFoundException extends RuntimeException
|
final class ActivationTokenNotFoundException extends DomainException
|
||||||
{
|
{
|
||||||
public static function withId(ActivationTokenId $tokenId): self
|
public static function withId(ActivationTokenId $tokenId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
final class CannotChangeGradingModeWithExistingGradesException extends RuntimeException
|
final class CannotChangeGradingModeWithExistingGradesException extends DomainException
|
||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class ClassNameInvalideException extends RuntimeException
|
final class ClassNameInvalideException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourLongueur(string $value, int $min, int $max): self
|
public static function pourLongueur(string $value, int $min, int $max): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class ClasseDejaExistanteException extends RuntimeException
|
final class ClasseDejaExistanteException extends DomainException
|
||||||
{
|
{
|
||||||
public static function avecNom(ClassName $name): self
|
public static function avecNom(ClassName $name): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class ClasseNonSupprimableException extends RuntimeException
|
final class ClasseNonSupprimableException extends DomainException
|
||||||
{
|
{
|
||||||
public static function carElevesAffectes(ClassId $classId, int $nombreEleves): self
|
public static function carElevesAffectes(ClassId $classId, int $nombreEleves): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class ClasseNotFoundException extends RuntimeException
|
final class ClasseNotFoundException extends DomainException
|
||||||
{
|
{
|
||||||
public static function withId(ClassId $classId): self
|
public static function withId(ClassId $classId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\User\StatutCompte;
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class CompteNonActivableException extends RuntimeException
|
final class CompteNonActivableException extends DomainException
|
||||||
{
|
{
|
||||||
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class DernierRoleNonRetirableException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pour(UserId $userId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Impossible de retirer le dernier rôle de l\'utilisateur %s.',
|
||||||
|
$userId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\User\Email;
|
use App\Administration\Domain\Model\User\Email;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class EmailDejaUtiliseeException extends RuntimeException
|
final class EmailDejaUtiliseeException extends DomainException
|
||||||
{
|
{
|
||||||
public static function dansTenant(Email $email, TenantId $tenantId): self
|
public static function dansTenant(Email $email, TenantId $tenantId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class EmailInvalideException extends RuntimeException
|
final class EmailInvalideException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourAdresse(string $email): self
|
public static function pourAdresse(string $email): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
|
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class GradingConfigurationNotFoundException extends RuntimeException
|
final class GradingConfigurationNotFoundException extends DomainException
|
||||||
{
|
{
|
||||||
public static function withId(SchoolGradingConfigurationId $id): self
|
public static function withId(SchoolGradingConfigurationId $id): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class InvalidPeriodCountException extends RuntimeException
|
final class InvalidPeriodCountException extends DomainException
|
||||||
{
|
{
|
||||||
public static function forType(string $type, int $expected, int $actual): self
|
public static function forType(string $type, int $expected, int $actual): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class InvalidPeriodDatesException extends RuntimeException
|
final class InvalidPeriodDatesException extends DomainException
|
||||||
{
|
{
|
||||||
public static function endBeforeStart(string $label, string $start, string $end): self
|
public static function endBeforeStart(string $label, string $start, string $end): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class PasswordResetTokenAlreadyUsedException extends RuntimeException
|
final class PasswordResetTokenAlreadyUsedException extends DomainException
|
||||||
{
|
{
|
||||||
public static function forToken(PasswordResetTokenId $tokenId): self
|
public static function forToken(PasswordResetTokenId $tokenId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class PasswordResetTokenExpiredException extends RuntimeException
|
final class PasswordResetTokenExpiredException extends DomainException
|
||||||
{
|
{
|
||||||
public static function forToken(PasswordResetTokenId $tokenId): self
|
public static function forToken(PasswordResetTokenId $tokenId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class PasswordResetTokenNotFoundException extends RuntimeException
|
final class PasswordResetTokenNotFoundException extends DomainException
|
||||||
{
|
{
|
||||||
public static function withId(PasswordResetTokenId $tokenId): self
|
public static function withId(PasswordResetTokenId $tokenId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class PeriodeAvecNotesException extends RuntimeException
|
final class PeriodeAvecNotesException extends DomainException
|
||||||
{
|
{
|
||||||
public static function confirmationRequise(string $label): self
|
public static function confirmationRequise(string $label): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class PeriodeNonTrouveeException extends RuntimeException
|
final class PeriodeNonTrouveeException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourSequence(int $sequence, string $academicYearId): self
|
public static function pourSequence(int $sequence, string $academicYearId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
final class PeriodesDejaConfigureesException extends RuntimeException
|
final class PeriodesDejaConfigureesException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourAnnee(string $academicYearId): self
|
public static function pourAnnee(string $academicYearId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
final class PeriodesNonConfigureesException extends RuntimeException
|
final class PeriodesNonConfigureesException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourAnnee(string $academicYearId): self
|
public static function pourAnnee(string $academicYearId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class PeriodsCoverageGapException extends RuntimeException
|
final class PeriodsCoverageGapException extends DomainException
|
||||||
{
|
{
|
||||||
public static function gapBetween(string $periodA, string $periodB): self
|
public static function gapBetween(string $periodA, string $periodB): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class PeriodsOverlapException extends RuntimeException
|
final class PeriodsOverlapException extends DomainException
|
||||||
{
|
{
|
||||||
public static function between(string $periodA, string $periodB): self
|
public static function between(string $periodA, string $periodB): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class RoleDejaAttribueException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pour(UserId $userId, Role $role): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le rôle « %s » est déjà attribué à l\'utilisateur %s.',
|
||||||
|
$role->label(),
|
||||||
|
$userId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class RoleNonAttribueException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pour(UserId $userId, Role $role): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le rôle « %s » n\'est pas attribué à l\'utilisateur %s.',
|
||||||
|
$role->label(),
|
||||||
|
$userId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ use function sprintf;
|
|||||||
*
|
*
|
||||||
* @see Story 1.6 - Gestion des sessions
|
* @see Story 1.6 - Gestion des sessions
|
||||||
*/
|
*/
|
||||||
final class SessionNotFoundException extends RuntimeException
|
final class SessionNotFoundException extends DomainException
|
||||||
{
|
{
|
||||||
public function __construct(TokenFamilyId $familyId)
|
public function __construct(TokenFamilyId $familyId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class SubjectCodeInvalideException extends RuntimeException
|
final class SubjectCodeInvalideException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourFormat(string $value, int $min, int $max): self
|
public static function pourFormat(string $value, int $min, int $max): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class SubjectColorInvalideException extends RuntimeException
|
final class SubjectColorInvalideException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourFormat(string $value): self
|
public static function pourFormat(string $value): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class SubjectDejaExistanteException extends RuntimeException
|
final class SubjectDejaExistanteException extends DomainException
|
||||||
{
|
{
|
||||||
public static function avecCode(SubjectCode $code): self
|
public static function avecCode(SubjectCode $code): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class SubjectNameInvalideException extends RuntimeException
|
final class SubjectNameInvalideException extends DomainException
|
||||||
{
|
{
|
||||||
public static function pourLongueur(string $value, int $min, int $max): self
|
public static function pourLongueur(string $value, int $min, int $max): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class SubjectNonSupprimableException extends RuntimeException
|
final class SubjectNonSupprimableException extends DomainException
|
||||||
{
|
{
|
||||||
public static function avecNotes(SubjectId $id): self
|
public static function avecNotes(SubjectId $id): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class SubjectNotFoundException extends RuntimeException
|
final class SubjectNotFoundException extends DomainException
|
||||||
{
|
{
|
||||||
public static function withId(SubjectId $id): self
|
public static function withId(SubjectId $id): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
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.
|
* 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
|
* @see Story 1.4 - Connexion utilisateur
|
||||||
*/
|
*/
|
||||||
final class TokenAlreadyRotatedException extends RuntimeException
|
final class TokenAlreadyRotatedException extends DomainException
|
||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ use function sprintf;
|
|||||||
* This indicates a concurrent request is processing the same token,
|
* This indicates a concurrent request is processing the same token,
|
||||||
* and the client should retry after a short delay.
|
* and the client should retry after a short delay.
|
||||||
*/
|
*/
|
||||||
final class TokenConsumptionInProgressException extends RuntimeException
|
final class TokenConsumptionInProgressException extends DomainException
|
||||||
{
|
{
|
||||||
public function __construct(string $tokenValue)
|
public function __construct(string $tokenValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Domain\Exception;
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ use function sprintf;
|
|||||||
* - Un audit log doit être créé
|
* - Un audit log doit être créé
|
||||||
* - Une alerte de sécurité peut être envoyée
|
* - Une alerte de sécurité peut être envoyée
|
||||||
*/
|
*/
|
||||||
final class TokenReplayDetectedException extends RuntimeException
|
final class TokenReplayDetectedException extends DomainException
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly TokenFamilyId $familyId,
|
public readonly TokenFamilyId $familyId,
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\User\Email;
|
use App\Administration\Domain\Model\User\Email;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class UserNotFoundException extends RuntimeException
|
final class UserNotFoundException extends DomainException
|
||||||
{
|
{
|
||||||
public static function withId(UserId $userId): self
|
public static function withId(UserId $userId): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\User\StatutCompte;
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class UtilisateurDejaInviteException extends RuntimeException
|
final class UtilisateurDejaInviteException extends DomainException
|
||||||
{
|
{
|
||||||
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\User\StatutCompte;
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class UtilisateurNonBlocableException extends RuntimeException
|
final class UtilisateurNonBlocableException extends DomainException
|
||||||
{
|
{
|
||||||
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\User\StatutCompte;
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use RuntimeException;
|
use DomainException;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
final class UtilisateurNonDeblocableException extends RuntimeException
|
final class UtilisateurNonDeblocableException extends DomainException
|
||||||
{
|
{
|
||||||
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ use App\Administration\Domain\Event\CompteActive;
|
|||||||
use App\Administration\Domain\Event\CompteCreated;
|
use App\Administration\Domain\Event\CompteCreated;
|
||||||
use App\Administration\Domain\Event\InvitationRenvoyee;
|
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||||
use App\Administration\Domain\Event\MotDePasseChange;
|
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\UtilisateurBloque;
|
||||||
use App\Administration\Domain\Event\UtilisateurDebloque;
|
use App\Administration\Domain\Event\UtilisateurDebloque;
|
||||||
use App\Administration\Domain\Event\UtilisateurInvite;
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
use App\Administration\Domain\Exception\CompteNonActivableException;
|
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\UtilisateurDejaInviteException;
|
||||||
use App\Administration\Domain\Exception\UtilisateurNonBlocableException;
|
use App\Administration\Domain\Exception\UtilisateurNonBlocableException;
|
||||||
use App\Administration\Domain\Exception\UtilisateurNonDeblocableException;
|
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\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||||
use App\Shared\Domain\AggregateRoot;
|
use App\Shared\Domain\AggregateRoot;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_values;
|
||||||
|
use function count;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate Root representing a user in Classeo.
|
* 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.
|
* The account lifecycle goes through multiple statuses: creation → activation.
|
||||||
* Minors (< 15 years) require parental consent before 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) ?DateTimeImmutable $blockedAt = null;
|
||||||
public private(set) ?string $blockedReason = null;
|
public private(set) ?string $blockedReason = null;
|
||||||
|
|
||||||
|
/** @var Role[] */
|
||||||
|
public private(set) array $roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Role[] $roles
|
||||||
|
*/
|
||||||
private function __construct(
|
private function __construct(
|
||||||
public private(set) UserId $id,
|
public private(set) UserId $id,
|
||||||
public private(set) Email $email,
|
public private(set) Email $email,
|
||||||
public private(set) Role $role,
|
array $roles,
|
||||||
public private(set) TenantId $tenantId,
|
public private(set) TenantId $tenantId,
|
||||||
public private(set) string $schoolName,
|
public private(set) string $schoolName,
|
||||||
public private(set) StatutCompte $statut,
|
public private(set) StatutCompte $statut,
|
||||||
@@ -49,6 +68,14 @@ final class User extends AggregateRoot
|
|||||||
public private(set) string $firstName = '',
|
public private(set) string $firstName = '',
|
||||||
public private(set) string $lastName = '',
|
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(
|
$user = new self(
|
||||||
id: UserId::generate(),
|
id: UserId::generate(),
|
||||||
email: $email,
|
email: $email,
|
||||||
role: $role,
|
roles: [$role],
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
schoolName: $schoolName,
|
schoolName: $schoolName,
|
||||||
statut: StatutCompte::EN_ATTENTE,
|
statut: StatutCompte::EN_ATTENTE,
|
||||||
@@ -84,6 +111,73 @@ final class User extends AggregateRoot
|
|||||||
return $user;
|
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.
|
* Activates the account with the hashed password.
|
||||||
*
|
*
|
||||||
@@ -167,7 +261,7 @@ final class User extends AggregateRoot
|
|||||||
$user = new self(
|
$user = new self(
|
||||||
id: UserId::generate(),
|
id: UserId::generate(),
|
||||||
email: $email,
|
email: $email,
|
||||||
role: $role,
|
roles: [$role],
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
schoolName: $schoolName,
|
schoolName: $schoolName,
|
||||||
statut: StatutCompte::EN_ATTENTE,
|
statut: StatutCompte::EN_ATTENTE,
|
||||||
@@ -296,12 +390,14 @@ final class User extends AggregateRoot
|
|||||||
/**
|
/**
|
||||||
* Reconstitutes a User from storage.
|
* Reconstitutes a User from storage.
|
||||||
*
|
*
|
||||||
|
* @param Role[] $roles
|
||||||
|
*
|
||||||
* @internal For Infrastructure use only
|
* @internal For Infrastructure use only
|
||||||
*/
|
*/
|
||||||
public static function reconstitute(
|
public static function reconstitute(
|
||||||
UserId $id,
|
UserId $id,
|
||||||
Email $email,
|
Email $email,
|
||||||
Role $role,
|
array $roles,
|
||||||
TenantId $tenantId,
|
TenantId $tenantId,
|
||||||
string $schoolName,
|
string $schoolName,
|
||||||
StatutCompte $statut,
|
StatutCompte $statut,
|
||||||
@@ -316,10 +412,14 @@ final class User extends AggregateRoot
|
|||||||
?DateTimeImmutable $blockedAt = null,
|
?DateTimeImmutable $blockedAt = null,
|
||||||
?string $blockedReason = null,
|
?string $blockedReason = null,
|
||||||
): self {
|
): self {
|
||||||
|
if ($roles === []) {
|
||||||
|
throw new InvalidArgumentException('Un utilisateur doit avoir au moins un rôle.');
|
||||||
|
}
|
||||||
|
|
||||||
$user = new self(
|
$user = new self(
|
||||||
id: $id,
|
id: $id,
|
||||||
email: $email,
|
email: $email,
|
||||||
role: $role,
|
roles: $roles,
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
schoolName: $schoolName,
|
schoolName: $schoolName,
|
||||||
statut: $statut,
|
statut: $statut,
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ final readonly class LogoutController
|
|||||||
->withSameSite($isSecure ? 'strict' : 'lax'),
|
->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;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ use App\Administration\Application\Command\InviteUser\InviteUserCommand;
|
|||||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||||
use App\Administration\Domain\Exception\EmailInvalideException;
|
use App\Administration\Domain\Exception\EmailInvalideException;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
use App\Administration\Infrastructure\Api\Resource\UserResource;
|
use App\Administration\Infrastructure\Api\Resource\UserResource;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
use App\Administration\Infrastructure\Security\UserVoter;
|
use App\Administration\Infrastructure\Security\UserVoter;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Override;
|
use Override;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
@@ -34,6 +40,7 @@ final readonly class InviteUserProcessor implements ProcessorInterface
|
|||||||
private MessageBusInterface $eventBus,
|
private MessageBusInterface $eventBus,
|
||||||
private AuthorizationCheckerInterface $authorizationChecker,
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
private Clock $clock,
|
private Clock $clock,
|
||||||
|
private Security $security,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +61,19 @@ final readonly class InviteUserProcessor implements ProcessorInterface
|
|||||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||||
$tenantConfig = $this->tenantContext->getCurrentTenantConfig();
|
$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 {
|
try {
|
||||||
$command = new InviteUserCommand(
|
$command = new InviteUserCommand(
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
@@ -62,6 +82,7 @@ final readonly class InviteUserProcessor implements ProcessorInterface
|
|||||||
role: $data->role ?? '',
|
role: $data->role ?? '',
|
||||||
firstName: $data->firstName ?? '',
|
firstName: $data->firstName ?? '',
|
||||||
lastName: $data->lastName ?? '',
|
lastName: $data->lastName ?? '',
|
||||||
|
roles: $data->roles ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
$user = ($this->handler)($command);
|
$user = ($this->handler)($command);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Application\Service\RoleContext;
|
||||||
|
use App\Administration\Domain\Exception\RoleNonAttribueException;
|
||||||
|
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SwitchRoleInput;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SwitchRoleOutput;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use DomainException;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<SwitchRoleInput, SwitchRoleOutput>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesCommand;
|
||||||
|
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesHandler;
|
||||||
|
use App\Administration\Domain\Exception\DernierRoleNonRetirableException;
|
||||||
|
use App\Administration\Domain\Exception\RoleDejaAttribueException;
|
||||||
|
use App\Administration\Domain\Exception\RoleNonAttribueException;
|
||||||
|
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||||
|
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\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<UserResource, UserResource>
|
||||||
|
*/
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Administration\Application\Service\RoleContext;
|
||||||
|
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\MyRolesOutput;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<MyRolesOutput>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Administration\Infrastructure\Api\Provider\MyRolesProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'MyRoles',
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/me/roles',
|
||||||
|
provider: MyRolesProvider::class,
|
||||||
|
name: 'get_my_roles',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class MyRolesOutput
|
||||||
|
{
|
||||||
|
#[ApiProperty(identifier: true, readable: false)]
|
||||||
|
public string $id = 'me';
|
||||||
|
|
||||||
|
/** @var array<array{value: string, label: string}> */
|
||||||
|
public array $roles = [];
|
||||||
|
|
||||||
|
public string $activeRole = '';
|
||||||
|
|
||||||
|
public string $activeRoleLabel = '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\SwitchRoleProcessor;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'SwitchRole',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/me/switch-role',
|
||||||
|
processor: SwitchRoleProcessor::class,
|
||||||
|
output: SwitchRoleOutput::class,
|
||||||
|
name: 'switch_role',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class SwitchRoleInput
|
||||||
|
{
|
||||||
|
#[Assert\NotBlank(message: 'Le rôle est requis.')]
|
||||||
|
public ?string $role = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
final readonly class SwitchRoleOutput
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $activeRole,
|
||||||
|
public string $activeRoleLabel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,15 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Administration\Application\Query\GetUsers\UserDto;
|
use App\Administration\Application\Query\GetUsers\UserDto;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
use App\Administration\Domain\Model\User\User;
|
use App\Administration\Domain\Model\User\User;
|
||||||
use App\Administration\Infrastructure\Api\Processor\BlockUserProcessor;
|
use App\Administration\Infrastructure\Api\Processor\BlockUserProcessor;
|
||||||
use App\Administration\Infrastructure\Api\Processor\InviteUserProcessor;
|
use App\Administration\Infrastructure\Api\Processor\InviteUserProcessor;
|
||||||
use App\Administration\Infrastructure\Api\Processor\ResendInvitationProcessor;
|
use App\Administration\Infrastructure\Api\Processor\ResendInvitationProcessor;
|
||||||
use App\Administration\Infrastructure\Api\Processor\UnblockUserProcessor;
|
use App\Administration\Infrastructure\Api\Processor\UnblockUserProcessor;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\UpdateUserRolesProcessor;
|
||||||
use App\Administration\Infrastructure\Api\Provider\UserCollectionProvider;
|
use App\Administration\Infrastructure\Api\Provider\UserCollectionProvider;
|
||||||
use App\Administration\Infrastructure\Api\Provider\UserItemProvider;
|
use App\Administration\Infrastructure\Api\Provider\UserItemProvider;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -24,6 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
* API Resource pour la gestion des utilisateurs.
|
* API Resource pour la gestion des utilisateurs.
|
||||||
*
|
*
|
||||||
* @see Story 2.5 - Création Comptes Utilisateurs
|
* @see Story 2.5 - Création Comptes Utilisateurs
|
||||||
|
* @see Story 2.6 - Attribution des Rôles
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
shortName: 'User',
|
shortName: 'User',
|
||||||
@@ -60,6 +64,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
processor: UnblockUserProcessor::class,
|
processor: UnblockUserProcessor::class,
|
||||||
name: 'unblock_user',
|
name: 'unblock_user',
|
||||||
),
|
),
|
||||||
|
new Put(
|
||||||
|
uriTemplate: '/users/{id}/roles',
|
||||||
|
read: false,
|
||||||
|
processor: UpdateUserRolesProcessor::class,
|
||||||
|
validationContext: ['groups' => ['Default', 'roles']],
|
||||||
|
name: 'update_user_roles',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
final class UserResource
|
final class UserResource
|
||||||
@@ -76,6 +87,11 @@ final class UserResource
|
|||||||
|
|
||||||
public ?string $roleLabel = null;
|
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'])]
|
#[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])]
|
||||||
public ?string $firstName = null;
|
public ?string $firstName = null;
|
||||||
|
|
||||||
@@ -107,6 +123,10 @@ final class UserResource
|
|||||||
$resource->email = (string) $user->email;
|
$resource->email = (string) $user->email;
|
||||||
$resource->role = $user->role->value;
|
$resource->role = $user->role->value;
|
||||||
$resource->roleLabel = $user->role->label();
|
$resource->roleLabel = $user->role->label();
|
||||||
|
$resource->roles = array_map(
|
||||||
|
static fn (Role $r) => $r->value,
|
||||||
|
$user->roles,
|
||||||
|
);
|
||||||
$resource->firstName = $user->firstName;
|
$resource->firstName = $user->firstName;
|
||||||
$resource->lastName = $user->lastName;
|
$resource->lastName = $user->lastName;
|
||||||
$resource->statut = $user->statut->value;
|
$resource->statut = $user->statut->value;
|
||||||
@@ -127,6 +147,7 @@ final class UserResource
|
|||||||
$resource->email = $dto->email;
|
$resource->email = $dto->email;
|
||||||
$resource->role = $dto->role;
|
$resource->role = $dto->role;
|
||||||
$resource->roleLabel = $dto->roleLabel;
|
$resource->roleLabel = $dto->roleLabel;
|
||||||
|
$resource->roles = $dto->roles;
|
||||||
$resource->firstName = $dto->firstName;
|
$resource->firstName = $dto->firstName;
|
||||||
$resource->lastName = $dto->lastName;
|
$resource->lastName = $dto->lastName;
|
||||||
$resource->statut = $dto->statut;
|
$resource->statut = $dto->statut;
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ final class CreateTestUserCommand extends Command
|
|||||||
$user = User::reconstitute(
|
$user = User::reconstitute(
|
||||||
id: UserId::generate(),
|
id: UserId::generate(),
|
||||||
email: new Email($email),
|
email: new Email($email),
|
||||||
role: $role,
|
roles: [$role],
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
schoolName: $schoolName,
|
schoolName: $schoolName,
|
||||||
statut: StatutCompte::ACTIF,
|
statut: StatutCompte::ACTIF,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ use DateTimeImmutable;
|
|||||||
use function in_array;
|
use function in_array;
|
||||||
|
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-based UserRepository for development and testing.
|
* Cache-based UserRepository for development and testing.
|
||||||
@@ -71,7 +74,7 @@ final readonly class CacheUserRepository implements UserRepository
|
|||||||
return null;
|
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();
|
$data = $item->get();
|
||||||
|
|
||||||
return $this->deserialize($data);
|
return $this->deserialize($data);
|
||||||
@@ -136,7 +139,7 @@ final readonly class CacheUserRepository implements UserRepository
|
|||||||
return [
|
return [
|
||||||
'id' => (string) $user->id,
|
'id' => (string) $user->id,
|
||||||
'email' => (string) $user->email,
|
'email' => (string) $user->email,
|
||||||
'role' => $user->role->value,
|
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
|
||||||
'tenant_id' => (string) $user->tenantId,
|
'tenant_id' => (string) $user->tenantId,
|
||||||
'school_name' => $user->schoolName,
|
'school_name' => $user->schoolName,
|
||||||
'statut' => $user->statut->value,
|
'statut' => $user->statut->value,
|
||||||
@@ -162,7 +165,8 @@ final readonly class CacheUserRepository implements UserRepository
|
|||||||
* @param array{
|
* @param array{
|
||||||
* id: string,
|
* id: string,
|
||||||
* email: string,
|
* email: string,
|
||||||
* role: string,
|
* roles?: string[],
|
||||||
|
* role?: string,
|
||||||
* tenant_id: string,
|
* tenant_id: string,
|
||||||
* school_name: string,
|
* school_name: string,
|
||||||
* statut: 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;
|
$invitedAt = ($data['invited_at'] ?? null) !== null ? new DateTimeImmutable($data['invited_at']) : null;
|
||||||
$blockedAt = ($data['blocked_at'] ?? null) !== null ? new DateTimeImmutable($data['blocked_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(
|
return User::reconstitute(
|
||||||
id: UserId::fromString($data['id']),
|
id: UserId::fromString($data['id']),
|
||||||
email: new Email($data['email']),
|
email: new Email($data['email']),
|
||||||
role: Role::from($data['role']),
|
roles: $roles,
|
||||||
tenantId: TenantId::fromString($data['tenant_id']),
|
tenantId: TenantId::fromString($data['tenant_id']),
|
||||||
schoolName: $data['school_name'],
|
schoolName: $data['school_name'],
|
||||||
statut: StatutCompte::from($data['statut']),
|
statut: StatutCompte::from($data['statut']),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use App\Shared\Domain\Clock;
|
|||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
@@ -101,6 +102,16 @@ final readonly class LoginSuccessHandler
|
|||||||
|
|
||||||
$response->headers->setCookie($cookie);
|
$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
|
// Reset the rate limiter for this email
|
||||||
$this->rateLimiter->reset($email);
|
$this->rateLimiter->reset($email);
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ final readonly class SecurityUserFactory
|
|||||||
email: (string) $domainUser->email,
|
email: (string) $domainUser->email,
|
||||||
hashedPassword: $domainUser->hashedPassword ?? '',
|
hashedPassword: $domainUser->hashedPassword ?? '',
|
||||||
tenantId: $domainUser->tenantId,
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ final class UserVoter extends Voter
|
|||||||
public const string BLOCK = 'USER_BLOCK';
|
public const string BLOCK = 'USER_BLOCK';
|
||||||
public const string UNBLOCK = 'USER_UNBLOCK';
|
public const string UNBLOCK = 'USER_UNBLOCK';
|
||||||
public const string RESEND_INVITATION = 'USER_RESEND_INVITATION';
|
public const string RESEND_INVITATION = 'USER_RESEND_INVITATION';
|
||||||
|
public const string MANAGE_ROLES = 'USER_MANAGE_ROLES';
|
||||||
|
|
||||||
private const array SUPPORTED_ATTRIBUTES = [
|
private const array SUPPORTED_ATTRIBUTES = [
|
||||||
self::VIEW,
|
self::VIEW,
|
||||||
@@ -37,6 +38,7 @@ final class UserVoter extends Voter
|
|||||||
self::BLOCK,
|
self::BLOCK,
|
||||||
self::UNBLOCK,
|
self::UNBLOCK,
|
||||||
self::RESEND_INVITATION,
|
self::RESEND_INVITATION,
|
||||||
|
self::MANAGE_ROLES,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[Override]
|
#[Override]
|
||||||
@@ -66,7 +68,7 @@ final class UserVoter extends Voter
|
|||||||
|
|
||||||
return match ($attribute) {
|
return match ($attribute) {
|
||||||
self::VIEW => $this->canView($roles),
|
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,
|
default => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Administration\Application\Port\ActiveRoleStore;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
final readonly class CacheActiveRoleStore implements ActiveRoleStore
|
||||||
|
{
|
||||||
|
private const string CACHE_KEY_PREFIX = 'active_role_';
|
||||||
|
private const int TTL_SECONDS = 604_800; // 7 jours
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private CacheItemPoolInterface $sessionsCache,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(User $user, Role $role): void
|
||||||
|
{
|
||||||
|
$item = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\AssignRole\AssignRoleCommand;
|
||||||
|
use App\Administration\Application\Command\AssignRole\AssignRoleHandler;
|
||||||
|
use App\Administration\Domain\Event\RoleAttribue;
|
||||||
|
use App\Administration\Domain\Exception\RoleDejaAttribueException;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AssignRoleHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemoryUserRepository $userRepository;
|
||||||
|
private AssignRoleHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\RemoveRole\RemoveRoleCommand;
|
||||||
|
use App\Administration\Application\Command\RemoveRole\RemoveRoleHandler;
|
||||||
|
use App\Administration\Domain\Event\RoleRetire;
|
||||||
|
use App\Administration\Domain\Exception\DernierRoleNonRetirableException;
|
||||||
|
use App\Administration\Domain\Exception\RoleNonAttribueException;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class RemoveRoleHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemoryUserRepository $userRepository;
|
||||||
|
private RemoveRoleHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesCommand;
|
||||||
|
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesHandler;
|
||||||
|
use App\Administration\Application\Port\ActiveRoleStore;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class UpdateUserRolesHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemoryUserRepository $userRepository;
|
||||||
|
private UpdateUserRolesHandler $handler;
|
||||||
|
private ActiveRoleStore $activeRoleStore;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service;
|
||||||
|
|
||||||
|
use App\Administration\Application\Port\ActiveRoleStore;
|
||||||
|
use App\Administration\Application\Service\RoleContext;
|
||||||
|
use App\Administration\Domain\Exception\RoleNonAttribueException;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DomainException;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class RoleContextTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemoryActiveRoleStore $store;
|
||||||
|
private RoleContext $roleContext;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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<string, Role> */
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ final class UserInvitationTest extends TestCase
|
|||||||
$user = User::reconstitute(
|
$user = User::reconstitute(
|
||||||
id: UserId::generate(),
|
id: UserId::generate(),
|
||||||
email: new Email('minor@example.com'),
|
email: new Email('minor@example.com'),
|
||||||
role: Role::ELEVE,
|
roles: [Role::ELEVE],
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
schoolName: self::SCHOOL_NAME,
|
schoolName: self::SCHOOL_NAME,
|
||||||
statut: StatutCompte::CONSENTEMENT_REQUIS,
|
statut: StatutCompte::CONSENTEMENT_REQUIS,
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\User;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\RoleAttribue;
|
||||||
|
use App\Administration\Domain\Event\RoleRetire;
|
||||||
|
use App\Administration\Domain\Exception\DernierRoleNonRetirableException;
|
||||||
|
use App\Administration\Domain\Exception\RoleDejaAttribueException;
|
||||||
|
use App\Administration\Domain\Exception\RoleNonAttribueException;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class UserRoleTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string SCHOOL_NAME = 'École Alpha';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function userIsCreatedWithSingleRole(): void
|
||||||
|
{
|
||||||
|
$user = $this->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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,10 +200,13 @@ final class LogoutControllerTest extends TestCase
|
|||||||
|
|
||||||
// THEN: Cookies are cleared (expired)
|
// THEN: Cookies are cleared (expired)
|
||||||
$cookies = $response->headers->getCookies();
|
$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) {
|
foreach ($cookies as $cookie) {
|
||||||
$this->assertSame('refresh_token', $cookie->getName());
|
|
||||||
$this->assertSame('', $cookie->getValue());
|
$this->assertSame('', $cookie->getValue());
|
||||||
$this->assertTrue($cookie->isCleared()); // Expiry in the past
|
$this->assertTrue($cookie->isCleared()); // Expiry in the past
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ final class CreateTestActivationTokenCommandTest extends TestCase
|
|||||||
return User::reconstitute(
|
return User::reconstitute(
|
||||||
id: UserId::generate(),
|
id: UserId::generate(),
|
||||||
email: new Email($email),
|
email: new Email($email),
|
||||||
role: Role::PARENT,
|
roles: [Role::PARENT],
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
schoolName: 'École Test',
|
schoolName: 'École Test',
|
||||||
statut: StatutCompte::ACTIF,
|
statut: StatutCompte::ACTIF,
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ final class DatabaseUserProviderTest extends TestCase
|
|||||||
return User::reconstitute(
|
return User::reconstitute(
|
||||||
id: UserId::generate(),
|
id: UserId::generate(),
|
||||||
email: new Email('user@example.com'),
|
email: new Email('user@example.com'),
|
||||||
role: Role::PARENT,
|
roles: [Role::PARENT],
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
schoolName: 'École Test',
|
schoolName: 'École Test',
|
||||||
statut: $statut,
|
statut: $statut,
|
||||||
|
|||||||
@@ -133,12 +133,12 @@ final class LoginSuccessHandlerTest extends TestCase
|
|||||||
// WHEN: Handler processes the event
|
// WHEN: Handler processes the event
|
||||||
$this->handler->onAuthenticationSuccess($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();
|
$cookies = $response->headers->getCookies();
|
||||||
$this->assertCount(1, $cookies);
|
$this->assertCount(2, $cookies);
|
||||||
$this->assertSame('refresh_token', $cookies[0]->getName());
|
$cookieNames = array_map(static fn ($c) => $c->getName(), $cookies);
|
||||||
$this->assertTrue($cookies[0]->isHttpOnly());
|
$this->assertContains('refresh_token', $cookieNames);
|
||||||
$this->assertSame('/api', $cookies[0]->getPath());
|
$this->assertContains('classeo_sid', $cookieNames);
|
||||||
|
|
||||||
// THEN: Refresh token is saved in repository
|
// THEN: Refresh token is saved in repository
|
||||||
$this->assertTrue(
|
$this->assertTrue(
|
||||||
@@ -304,10 +304,12 @@ final class LoginSuccessHandlerTest extends TestCase
|
|||||||
// WHEN: Handler processes the event
|
// WHEN: Handler processes the event
|
||||||
$this->handler->onAuthenticationSuccess($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();
|
$cookies = $response->headers->getCookies();
|
||||||
$this->assertCount(1, $cookies);
|
$this->assertCount(2, $cookies);
|
||||||
$this->assertFalse($cookies[0]->isSecure());
|
foreach ($cookies as $cookie) {
|
||||||
|
$this->assertFalse($cookie->isSecure());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createRequest(): Request
|
private function createRequest(): Request
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ final class SecurityUserTest extends TestCase
|
|||||||
return User::reconstitute(
|
return User::reconstitute(
|
||||||
id: UserId::generate(),
|
id: UserId::generate(),
|
||||||
email: new Email('user@example.com'),
|
email: new Email('user@example.com'),
|
||||||
role: $role,
|
roles: [$role],
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
schoolName: 'École Test',
|
schoolName: 'École Test',
|
||||||
statut: StatutCompte::ACTIF,
|
statut: StatutCompte::ACTIF,
|
||||||
|
|||||||
@@ -121,6 +121,48 @@ final class UserVoterTest extends TestCase
|
|||||||
self::assertSame(UserVoter::ACCESS_DENIED, $result);
|
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
|
private function voteWithRole(string $role, string $attribute): int
|
||||||
{
|
{
|
||||||
$user = $this->createMock(UserInterface::class);
|
$user = $this->createMock(UserInterface::class);
|
||||||
|
|||||||
@@ -69,16 +69,11 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
|
|||||||
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
|
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pedagogy card is visible on admin dashboard', async ({ page, browserName }) => {
|
test('pedagogy card is visible on admin dashboard', async ({ page }) => {
|
||||||
// Svelte 5 delegated onclick is not triggered by Playwright click on webkit
|
|
||||||
test.skip(browserName === 'webkit', 'Demo role switcher click not supported on webkit');
|
|
||||||
|
|
||||||
await loginAsAdmin(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`);
|
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({
|
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
getRoles,
|
||||||
|
getActiveRole,
|
||||||
|
hasMultipleRoles,
|
||||||
|
switchTo,
|
||||||
|
getIsSwitching
|
||||||
|
} from '$features/roles/roleContext.svelte';
|
||||||
|
|
||||||
|
let selectedRole = $state(getActiveRole() ?? '');
|
||||||
|
let switchError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Sync selected role when active role changes externally
|
||||||
|
$effect(() => {
|
||||||
|
const active = getActiveRole();
|
||||||
|
if (active) {
|
||||||
|
selectedRole = active;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSwitch() {
|
||||||
|
if (selectedRole === getActiveRole()) return;
|
||||||
|
|
||||||
|
switchError = null;
|
||||||
|
const success = await switchTo(selectedRole);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
switchError = 'Erreur lors du basculement';
|
||||||
|
selectedRole = getActiveRole() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasMultipleRoles()}
|
||||||
|
<div class="role-switcher">
|
||||||
|
<label for="role-switcher" class="role-switcher-label">Vue :</label>
|
||||||
|
<select
|
||||||
|
id="role-switcher"
|
||||||
|
bind:value={selectedRole}
|
||||||
|
onchange={handleSwitch}
|
||||||
|
disabled={getIsSwitching()}
|
||||||
|
class="role-switcher-select"
|
||||||
|
>
|
||||||
|
{#each getRoles() as role}
|
||||||
|
<option value={role.value}>{role.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if getIsSwitching()}
|
||||||
|
<span class="role-switcher-spinner"></span>
|
||||||
|
{/if}
|
||||||
|
{#if switchError}
|
||||||
|
<span class="role-switcher-error">{switchError}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.role-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-select {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent-primary, #0ea5e9);
|
||||||
|
background: var(--accent-primary-light, #e0f2fe);
|
||||||
|
border: 1px solid var(--accent-primary, #0ea5e9);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-select:hover:not(:disabled) {
|
||||||
|
background: var(--accent-primary, #0ea5e9);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-select:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border-subtle, #e2e8f0);
|
||||||
|
border-top-color: var(--accent-primary, #0ea5e9);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-alert, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
frontend/src/lib/features/roles/api/roles.ts
Normal file
86
frontend/src/lib/features/roles/api/roles.ts
Normal file
@@ -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<MyRolesResponse> {
|
||||||
|
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<SwitchRoleResponse> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
frontend/src/lib/features/roles/roleContext.svelte.ts
Normal file
111
frontend/src/lib/features/roles/roleContext.svelte.ts
Normal file
@@ -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<RoleInfo[]>([]);
|
||||||
|
let activeRole = $state<string | null>(null);
|
||||||
|
let activeRoleLabel = $state<string | null>(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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import { onLogout } from '$lib/auth/auth.svelte';
|
import { onLogout } from '$lib/auth/auth.svelte';
|
||||||
|
import { resetRoleContext } from '$features/roles/roleContext.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
// Clear user-specific caches on logout to prevent cross-account data leakage
|
// Clear user-specific caches on logout to prevent cross-account data leakage
|
||||||
onLogout(() => {
|
onLogout(() => {
|
||||||
queryClient.removeQueries({ queryKey: ['sessions'] });
|
queryClient.removeQueries({ queryKey: ['sessions'] });
|
||||||
|
resetRoleContext();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { logout } from '$lib/auth/auth.svelte';
|
import { logout } from '$lib/auth/auth.svelte';
|
||||||
|
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
|
||||||
|
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let isLoggingOut = $state(false);
|
let isLoggingOut = $state(false);
|
||||||
|
|
||||||
|
// Load user roles on mount for multi-role context switching (FR5)
|
||||||
|
$effect(() => {
|
||||||
|
untrack(() => fetchRoles());
|
||||||
|
});
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
isLoggingOut = true;
|
isLoggingOut = true;
|
||||||
try {
|
try {
|
||||||
|
resetRoleContext();
|
||||||
await logout();
|
await logout();
|
||||||
} finally {
|
} finally {
|
||||||
isLoggingOut = false;
|
isLoggingOut = false;
|
||||||
@@ -38,6 +47,7 @@
|
|||||||
<span class="logo-text">Classeo</span>
|
<span class="logo-text">Classeo</span>
|
||||||
</button>
|
</button>
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
|
<RoleSwitcher />
|
||||||
<a href="/dashboard" class="nav-link">Tableau de bord</a>
|
<a href="/dashboard" class="nav-link">Tableau de bord</a>
|
||||||
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
|
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
|
||||||
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
|
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiBaseUrl } from '$lib/api/config';
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
import { authenticatedFetch, getCurrentUserId } from '$lib/auth';
|
import { authenticatedFetch, getCurrentUserId } from '$lib/auth';
|
||||||
|
import { updateUserRoles } from '$features/roles/api/roles';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface User {
|
interface User {
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
roleLabel: string;
|
roleLabel: string;
|
||||||
|
roles: string[];
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
statut: string;
|
statut: string;
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
|
|
||||||
// Role options (admin can assign these roles)
|
// Role options (admin can assign these roles)
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'ROLE_SUPER_ADMIN', label: 'Super Admin' },
|
||||||
{ value: 'ROLE_ADMIN', label: 'Directeur' },
|
{ value: 'ROLE_ADMIN', label: 'Directeur' },
|
||||||
{ value: 'ROLE_PROF', label: 'Enseignant' },
|
{ value: 'ROLE_PROF', label: 'Enseignant' },
|
||||||
{ value: 'ROLE_VIE_SCOLAIRE', label: 'Vie Scolaire' },
|
{ value: 'ROLE_VIE_SCOLAIRE', label: 'Vie Scolaire' },
|
||||||
@@ -53,7 +56,7 @@
|
|||||||
let newFirstName = $state('');
|
let newFirstName = $state('');
|
||||||
let newLastName = $state('');
|
let newLastName = $state('');
|
||||||
let newEmail = $state('');
|
let newEmail = $state('');
|
||||||
let newRole = $state('');
|
let newRoles = $state<string[]>([]);
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let isResending = $state<string | null>(null);
|
let isResending = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -64,6 +67,12 @@
|
|||||||
let isBlocking = $state(false);
|
let isBlocking = $state(false);
|
||||||
let isUnblocking = $state<string | null>(null);
|
let isUnblocking = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Roles modal state
|
||||||
|
let showRolesModal = $state(false);
|
||||||
|
let rolesTargetUser = $state<User | null>(null);
|
||||||
|
let selectedRoles = $state<string[]>([]);
|
||||||
|
let isSavingRoles = $state(false);
|
||||||
|
|
||||||
// Load users on mount
|
// Load users on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
@@ -106,7 +115,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateUser() {
|
async function handleCreateUser() {
|
||||||
if (!newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || !newRole) return;
|
if (!newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || newRoles.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
@@ -121,7 +130,7 @@
|
|||||||
firstName: newFirstName.trim(),
|
firstName: newFirstName.trim(),
|
||||||
lastName: newLastName.trim(),
|
lastName: newLastName.trim(),
|
||||||
email: newEmail.trim().toLowerCase(),
|
email: newEmail.trim().toLowerCase(),
|
||||||
role: newRole
|
roles: newRoles
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,7 +205,7 @@
|
|||||||
newFirstName = '';
|
newFirstName = '';
|
||||||
newLastName = '';
|
newLastName = '';
|
||||||
newEmail = '';
|
newEmail = '';
|
||||||
newRole = '';
|
newRoles = [];
|
||||||
error = null;
|
error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +257,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canBlockUser(user: User): boolean {
|
function canBlockUser(user: User): boolean {
|
||||||
return user.statut !== 'suspended' && user.statut !== 'archived' && user.id !== getCurrentUserId();
|
return user.statut === 'active' && user.id !== getCurrentUserId();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBlockModal(user: User) {
|
function openBlockModal(user: User) {
|
||||||
@@ -346,6 +355,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRolesModal(user: User) {
|
||||||
|
rolesTargetUser = user;
|
||||||
|
selectedRoles = [...(user.roles ?? [user.role])];
|
||||||
|
showRolesModal = true;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRolesModal() {
|
||||||
|
showRolesModal = false;
|
||||||
|
rolesTargetUser = null;
|
||||||
|
selectedRoles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRole(roleValue: string) {
|
||||||
|
if (selectedRoles.includes(roleValue)) {
|
||||||
|
if (selectedRoles.length > 1) {
|
||||||
|
selectedRoles = selectedRoles.filter((r) => r !== roleValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedRoles = [...selectedRoles, roleValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveRoles() {
|
||||||
|
if (!rolesTargetUser || selectedRoles.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSavingRoles = true;
|
||||||
|
error = null;
|
||||||
|
await updateUserRoles(rolesTargetUser.id, selectedRoles);
|
||||||
|
successMessage = `Les rôles de ${rolesTargetUser.firstName} ${rolesTargetUser.lastName} ont été mis à jour.`;
|
||||||
|
closeRolesModal();
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la mise à jour des rôles';
|
||||||
|
} finally {
|
||||||
|
isSavingRoles = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNewRole(roleValue: string) {
|
||||||
|
if (newRoles.includes(roleValue)) {
|
||||||
|
newRoles = newRoles.filter((r) => r !== roleValue);
|
||||||
|
} else {
|
||||||
|
newRoles = [...newRoles, roleValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleLabelByValue(roleValue: string): string {
|
||||||
|
const found = ROLE_OPTIONS.find((r) => r.value === roleValue);
|
||||||
|
return found?.label ?? roleValue;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
function formatDate(dateStr: string | null): string {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
@@ -445,7 +507,15 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="user-email">{user.email}</td>
|
<td class="user-email">{user.email}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="role-badge">{user.roleLabel}</span>
|
<div class="role-badges">
|
||||||
|
{#if user.roles && user.roles.length > 0}
|
||||||
|
{#each user.roles as roleValue}
|
||||||
|
<span class="role-badge">{getRoleLabelByValue(roleValue)}</span>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="role-badge">{user.roleLabel}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}">
|
<span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}">
|
||||||
@@ -459,6 +529,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="date-cell">{formatDate(user.invitedAt)}</td>
|
<td class="date-cell">{formatDate(user.invitedAt)}</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
|
{#if user.id !== getCurrentUserId()}
|
||||||
|
<button
|
||||||
|
class="btn-secondary btn-sm"
|
||||||
|
onclick={() => openRolesModal(user)}
|
||||||
|
>
|
||||||
|
Rôles
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if canResendInvitation(user)}
|
{#if canResendInvitation(user)}
|
||||||
<button
|
<button
|
||||||
class="btn-secondary btn-sm"
|
class="btn-secondary btn-sm"
|
||||||
@@ -565,15 +643,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<fieldset class="roles-fieldset">
|
||||||
<label for="user-role">Rôle *</label>
|
<legend class="roles-legend">Rôle(s) *</legend>
|
||||||
<select id="user-role" bind:value={newRole} required>
|
{#each ROLE_OPTIONS as role}
|
||||||
<option value="">-- Sélectionner un rôle --</option>
|
<label class="role-checkbox-label">
|
||||||
{#each ROLE_OPTIONS as role}
|
<input
|
||||||
<option value={role.value}>{role.label}</option>
|
type="checkbox"
|
||||||
{/each}
|
checked={newRoles.includes(role.value)}
|
||||||
</select>
|
onchange={() => toggleNewRole(role.value)}
|
||||||
</div>
|
/>
|
||||||
|
<span class="role-checkbox-text">{role.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-hint-block">
|
<div class="form-hint-block">
|
||||||
Un email d'invitation sera automatiquement envoyé à l'utilisateur.
|
Un email d'invitation sera automatiquement envoyé à l'utilisateur.
|
||||||
@@ -587,7 +669,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
disabled={isSubmitting || !newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || !newRole}
|
disabled={isSubmitting || !newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || newRoles.length === 0}
|
||||||
>
|
>
|
||||||
{#if isSubmitting}
|
{#if isSubmitting}
|
||||||
Envoi de l'invitation...
|
Envoi de l'invitation...
|
||||||
@@ -663,6 +745,74 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Roles Modal -->
|
||||||
|
{#if showRolesModal && rolesTargetUser}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="modal-overlay" onclick={closeRolesModal} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="roles-modal-title"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') closeRolesModal(); }}
|
||||||
|
>
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="roles-modal-title">Modifier les rôles</h2>
|
||||||
|
<button class="modal-close" onclick={closeRolesModal} aria-label="Fermer">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="roles-modal-user">
|
||||||
|
{rolesTargetUser.firstName} {rolesTargetUser.lastName} ({rolesTargetUser.email})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<fieldset class="roles-fieldset">
|
||||||
|
<legend class="roles-legend">Rôles attribués</legend>
|
||||||
|
{#each ROLE_OPTIONS as role}
|
||||||
|
<label class="role-checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onchange={() => toggleRole(role.value)}
|
||||||
|
disabled={selectedRoles.includes(role.value) && selectedRoles.length === 1}
|
||||||
|
/>
|
||||||
|
<span class="role-checkbox-text">{role.label}</span>
|
||||||
|
{#if selectedRoles.includes(role.value) && selectedRoles.length === 1}
|
||||||
|
<span class="role-checkbox-hint">(dernier rôle)</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-hint-block">
|
||||||
|
Un utilisateur doit avoir au moins un rôle. Les utilisateurs avec plusieurs rôles
|
||||||
|
pourront basculer entre leurs différentes vues.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={closeRolesModal} disabled={isSavingRoles}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
onclick={handleSaveRoles}
|
||||||
|
disabled={isSavingRoles || selectedRoles.length === 0}
|
||||||
|
>
|
||||||
|
{#if isSavingRoles}
|
||||||
|
Enregistrement...
|
||||||
|
{:else}
|
||||||
|
Enregistrer
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.users-page {
|
.users-page {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -1129,8 +1279,7 @@
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input {
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
@@ -1139,8 +1288,7 @@
|
|||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus {
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
@@ -1191,6 +1339,59 @@
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Role badges */
|
||||||
|
.role-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roles modal */
|
||||||
|
.roles-modal-user {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-fieldset {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-legend {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-checkbox-label input[type='checkbox'] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
accent-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-checkbox-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-checkbox-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.filters-bar {
|
.filters-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { logout } from '$lib/auth/auth.svelte';
|
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte';
|
||||||
|
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
|
||||||
|
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let isLoggingOut = $state(false);
|
let isLoggingOut = $state(false);
|
||||||
|
|
||||||
// Note: Authentication is handled by authenticatedFetch in the page component.
|
// Load user roles on mount for multi-role context switching (FR5)
|
||||||
// If not authenticated, authenticatedFetch will attempt refresh and redirect to /login if needed.
|
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
|
||||||
|
$effect(() => {
|
||||||
|
untrack(async () => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
const refreshed = await refreshToken();
|
||||||
|
if (!refreshed) return;
|
||||||
|
}
|
||||||
|
fetchRoles();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
isLoggingOut = true;
|
isLoggingOut = true;
|
||||||
try {
|
try {
|
||||||
|
resetRoleContext();
|
||||||
await logout();
|
await logout();
|
||||||
} finally {
|
} finally {
|
||||||
isLoggingOut = false;
|
isLoggingOut = false;
|
||||||
@@ -33,6 +46,7 @@
|
|||||||
<span class="logo-text">Classeo</span>
|
<span class="logo-text">Classeo</span>
|
||||||
</button>
|
</button>
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
|
<RoleSwitcher />
|
||||||
<a href="/dashboard" class="nav-link active">Tableau de bord</a>
|
<a href="/dashboard" class="nav-link active">Tableau de bord</a>
|
||||||
<button class="nav-button" onclick={goSettings}>Parametres</button>
|
<button class="nav-button" onclick={goSettings}>Parametres</button>
|
||||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
|||||||
@@ -5,12 +5,33 @@
|
|||||||
import DashboardTeacher from '$lib/components/organisms/Dashboard/DashboardTeacher.svelte';
|
import DashboardTeacher from '$lib/components/organisms/Dashboard/DashboardTeacher.svelte';
|
||||||
import DashboardStudent from '$lib/components/organisms/Dashboard/DashboardStudent.svelte';
|
import DashboardStudent from '$lib/components/organisms/Dashboard/DashboardStudent.svelte';
|
||||||
import DashboardAdmin from '$lib/components/organisms/Dashboard/DashboardAdmin.svelte';
|
import DashboardAdmin from '$lib/components/organisms/Dashboard/DashboardAdmin.svelte';
|
||||||
|
import { getActiveRole, getIsLoading } from '$features/roles/roleContext.svelte';
|
||||||
|
|
||||||
type UserRole = 'parent' | 'teacher' | 'student' | 'admin' | 'direction';
|
type DashboardView = 'parent' | 'teacher' | 'student' | 'admin';
|
||||||
|
|
||||||
// For now, default to parent role with demo data
|
const ROLE_TO_VIEW: Record<string, DashboardView> = {
|
||||||
// TODO: Fetch real user profile from /api/me when endpoint is implemented
|
ROLE_PARENT: 'parent',
|
||||||
let userRole = $state<UserRole>('parent');
|
ROLE_PROF: 'teacher',
|
||||||
|
ROLE_ELEVE: 'student',
|
||||||
|
ROLE_ADMIN: 'admin',
|
||||||
|
ROLE_SUPER_ADMIN: 'admin',
|
||||||
|
ROLE_VIE_SCOLAIRE: 'admin',
|
||||||
|
ROLE_SECRETARIAT: 'admin'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback demo role when not authenticated or roles not loaded
|
||||||
|
let demoRole = $state<DashboardView>('parent');
|
||||||
|
|
||||||
|
// Use real role context if available, otherwise fallback to demo
|
||||||
|
let dashboardView = $derived<DashboardView>(
|
||||||
|
ROLE_TO_VIEW[getActiveRole() ?? ''] ?? demoRole
|
||||||
|
);
|
||||||
|
|
||||||
|
// True when roles come from the API (user is authenticated)
|
||||||
|
let hasRoleContext = $derived(getActiveRole() !== null);
|
||||||
|
|
||||||
|
// True when role loading has started (indicates an authenticated user)
|
||||||
|
let isRoleLoading = $derived(getIsLoading());
|
||||||
|
|
||||||
// Simulated first login detection (in real app, this comes from API)
|
// Simulated first login detection (in real app, this comes from API)
|
||||||
let isFirstLogin = $state(true);
|
let isFirstLogin = $state(true);
|
||||||
@@ -26,16 +47,13 @@
|
|||||||
|
|
||||||
function handleToggleSerenity(enabled: boolean) {
|
function handleToggleSerenity(enabled: boolean) {
|
||||||
serenityEnabled = enabled;
|
serenityEnabled = enabled;
|
||||||
// TODO: POST to /api/me/preferences when backend is ready
|
|
||||||
console.log('Serenity score preference updated:', enabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cast demo data to proper type
|
// Cast demo data to proper type
|
||||||
const typedDemoData = demoData as DemoData;
|
const typedDemoData = demoData as DemoData;
|
||||||
|
|
||||||
// Allow switching roles for demo purposes
|
function switchDemoRole(role: DashboardView) {
|
||||||
function switchRole(role: UserRole) {
|
demoRole = role;
|
||||||
userRole = role;
|
|
||||||
isFirstLogin = false;
|
isFirstLogin = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -44,17 +62,25 @@
|
|||||||
<title>Tableau de bord - Classeo</title>
|
<title>Tableau de bord - Classeo</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- Demo role switcher - TODO: Remove when real authentication is implemented -->
|
<!-- Loading state when roles are being fetched -->
|
||||||
<!-- This will be hidden once we can determine user role from /api/me -->
|
{#if isRoleLoading}
|
||||||
<div class="demo-controls">
|
<div class="loading-state">
|
||||||
<span class="demo-label">Démo - Changer de rôle :</span>
|
<div class="spinner"></div>
|
||||||
<button class:active={userRole === 'parent'} onclick={() => switchRole('parent')}>Parent</button>
|
<p>Chargement du tableau de bord...</p>
|
||||||
<button class:active={userRole === 'teacher'} onclick={() => switchRole('teacher')}>Enseignant</button>
|
</div>
|
||||||
<button class:active={userRole === 'student'} onclick={() => switchRole('student')}>Élève</button>
|
{:else if !hasRoleContext && !isRoleLoading}
|
||||||
<button class:active={userRole === 'admin'} onclick={() => switchRole('admin')}>Admin</button>
|
<!-- Demo role switcher shown when not authenticated (no role context from API) -->
|
||||||
</div>
|
<!-- The RoleSwitcher in the header handles multi-role switching for authenticated users -->
|
||||||
|
<div class="demo-controls">
|
||||||
|
<span class="demo-label">Démo - Changer de rôle :</span>
|
||||||
|
<button class:active={demoRole === 'parent'} onclick={() => switchDemoRole('parent')}>Parent</button>
|
||||||
|
<button class:active={demoRole === 'teacher'} onclick={() => switchDemoRole('teacher')}>Enseignant</button>
|
||||||
|
<button class:active={demoRole === 'student'} onclick={() => switchDemoRole('student')}>Élève</button>
|
||||||
|
<button class:active={demoRole === 'admin'} onclick={() => switchDemoRole('admin')}>Admin</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if userRole === 'parent'}
|
{#if dashboardView === 'parent'}
|
||||||
<DashboardParent
|
<DashboardParent
|
||||||
demoData={typedDemoData}
|
demoData={typedDemoData}
|
||||||
{isFirstLogin}
|
{isFirstLogin}
|
||||||
@@ -64,11 +90,11 @@
|
|||||||
{childName}
|
{childName}
|
||||||
onToggleSerenity={handleToggleSerenity}
|
onToggleSerenity={handleToggleSerenity}
|
||||||
/>
|
/>
|
||||||
{:else if userRole === 'teacher'}
|
{:else if dashboardView === 'teacher'}
|
||||||
<DashboardTeacher isLoading={false} {hasRealData} />
|
<DashboardTeacher isLoading={false} {hasRealData} />
|
||||||
{:else if userRole === 'student'}
|
{:else if dashboardView === 'student'}
|
||||||
<DashboardStudent demoData={typedDemoData} isLoading={false} {hasRealData} isMinor={true} />
|
<DashboardStudent demoData={typedDemoData} isLoading={false} {hasRealData} isMinor={true} />
|
||||||
{:else if userRole === 'admin' || userRole === 'direction'}
|
{:else if dashboardView === 'admin'}
|
||||||
<DashboardAdmin
|
<DashboardAdmin
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
{hasRealData}
|
{hasRealData}
|
||||||
@@ -77,6 +103,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.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 {
|
.demo-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user