feat: Activation de compte utilisateur avec validation token
L'inscription Classeo se fait via invitation : un admin crée un compte, l'utilisateur reçoit un lien d'activation par email pour définir son mot de passe. Ce flow sécurisé évite les inscriptions non autorisées et garantit que seuls les utilisateurs légitimes accèdent au système. Points clés de l'implémentation : - Tokens d'activation à usage unique stockés en cache (Redis/filesystem) - Validation du consentement parental pour les mineurs < 15 ans (RGPD) - L'échec d'activation ne consume pas le token (retry possible) - Users dans un cache séparé sans TTL (pas d'expiration) - Hot reload en dev (FrankenPHP sans mode worker) Story: 1.3 - Inscription et activation de compte
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class ActivationTokenGenerated implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ActivationTokenId $tokenId,
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->tokenId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class ActivationTokenUsed implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ActivationTokenId $tokenId,
|
||||
public string $userId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->tokenId->value;
|
||||
}
|
||||
}
|
||||
42
backend/src/Administration/Domain/Event/CompteActive.php
Normal file
42
backend/src/Administration/Domain/Event/CompteActive.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Event emitted when a user account is activated.
|
||||
*
|
||||
* This event triggers the sending of a confirmation email
|
||||
* and any other side effects related to account activation.
|
||||
*/
|
||||
final readonly class CompteActive implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
public string $role,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
private UuidInterface $aggregateId,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->aggregateId;
|
||||
}
|
||||
}
|
||||
39
backend/src/Administration/Domain/Event/CompteCreated.php
Normal file
39
backend/src/Administration/Domain/Event/CompteCreated.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Event émis lors de la création d'un nouveau compte utilisateur.
|
||||
*/
|
||||
final readonly class CompteCreated 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationTokenAlreadyUsedException extends RuntimeException
|
||||
{
|
||||
public static function forToken(ActivationTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token "%s" has already been used.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationTokenExpiredException extends RuntimeException
|
||||
{
|
||||
public static function forToken(ActivationTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token "%s" has expired.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationTokenNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withId(ActivationTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token with ID "%s" not found.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
|
||||
public static function withTokenValue(string $tokenValue): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Activation token with value "%s" not found.',
|
||||
$tokenValue,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class CompteNonActivableException extends RuntimeException
|
||||
{
|
||||
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le compte "%s" ne peut pas être activé car son statut est "%s".',
|
||||
$userId,
|
||||
$statut->value,
|
||||
));
|
||||
}
|
||||
|
||||
public static function carConsentementManquant(UserId $userId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le compte "%s" ne peut pas être activé : consentement parental manquant.',
|
||||
$userId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class EmailInvalideException extends RuntimeException
|
||||
{
|
||||
public static function pourAdresse(string $email): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'adresse email "%s" n\'est pas valide.',
|
||||
$email,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class UserNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withId(UserId $userId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'User with ID "%s" not found.',
|
||||
$userId,
|
||||
));
|
||||
}
|
||||
|
||||
public static function withEmail(Email $email): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'User with email "%s" not found.',
|
||||
$email,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Administration\Domain\Event\ActivationTokenGenerated;
|
||||
use App\Administration\Domain\Event\ActivationTokenUsed;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationToken extends AggregateRoot
|
||||
{
|
||||
private const int EXPIRATION_DAYS = 7;
|
||||
|
||||
public private(set) ?DateTimeImmutable $usedAt = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ActivationTokenId $id,
|
||||
public private(set) string $tokenValue,
|
||||
public private(set) string $userId,
|
||||
public private(set) string $email,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) string $role,
|
||||
public private(set) string $schoolName,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
public private(set) DateTimeImmutable $expiresAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function generate(
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
string $role,
|
||||
string $schoolName,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: ActivationTokenId::generate(),
|
||||
tokenValue: Uuid::uuid4()->toString(),
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $createdAt,
|
||||
expiresAt: $createdAt->modify(sprintf('+%d days', self::EXPIRATION_DAYS)),
|
||||
);
|
||||
|
||||
$token->recordEvent(new ActivationTokenGenerated(
|
||||
tokenId: $token->id,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute an ActivationToken from storage.
|
||||
* Does NOT record domain events (this is not a new creation).
|
||||
*
|
||||
* @internal For use by Infrastructure layer only
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ActivationTokenId $id,
|
||||
string $tokenValue,
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
string $role,
|
||||
string $schoolName,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $expiresAt,
|
||||
?DateTimeImmutable $usedAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: $id,
|
||||
tokenValue: $tokenValue,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $createdAt,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
|
||||
$token->usedAt = $usedAt;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function isExpired(DateTimeImmutable $at): bool
|
||||
{
|
||||
return $at >= $this->expiresAt;
|
||||
}
|
||||
|
||||
public function isUsed(): bool
|
||||
{
|
||||
return $this->usedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the token can be used (not expired, not already used).
|
||||
* Does NOT mark the token as used - use use() for that after successful activation.
|
||||
*
|
||||
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||
* @throws ActivationTokenExpiredException if token is expired
|
||||
*/
|
||||
public function validateForUse(DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->isUsed()) {
|
||||
throw ActivationTokenAlreadyUsedException::forToken($this->id);
|
||||
}
|
||||
|
||||
if ($this->isExpired($at)) {
|
||||
throw ActivationTokenExpiredException::forToken($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the token as used. Should only be called after successful user activation.
|
||||
*
|
||||
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||
* @throws ActivationTokenExpiredException if token is expired
|
||||
*/
|
||||
public function use(DateTimeImmutable $at): void
|
||||
{
|
||||
$this->validateForUse($at);
|
||||
|
||||
$this->usedAt = $at;
|
||||
|
||||
$this->recordEvent(new ActivationTokenUsed(
|
||||
tokenId: $this->id,
|
||||
userId: $this->userId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ActivationTokenId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ConsentementParental;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Value Object représentant le consentement parental.
|
||||
*
|
||||
* Requis pour les utilisateurs mineurs (< 15 ans) conformément au RGPD (NFR-C1).
|
||||
* Le consentement doit être donné par un parent avant que l'élève puisse activer son compte.
|
||||
*/
|
||||
final readonly class ConsentementParental
|
||||
{
|
||||
public function __construct(
|
||||
public string $parentId,
|
||||
public string $eleveId,
|
||||
public DateTimeImmutable $dateConsentement,
|
||||
public string $ipAddress,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau consentement parental horodaté.
|
||||
*/
|
||||
public static function accorder(
|
||||
string $parentId,
|
||||
string $eleveId,
|
||||
DateTimeImmutable $at,
|
||||
string $ipAddress,
|
||||
): self {
|
||||
return new self(
|
||||
parentId: $parentId,
|
||||
eleveId: $eleveId,
|
||||
dateConsentement: $at,
|
||||
ipAddress: $ipAddress,
|
||||
);
|
||||
}
|
||||
|
||||
public function estPourEleve(string $eleveId): bool
|
||||
{
|
||||
return $this->eleveId === $eleveId;
|
||||
}
|
||||
}
|
||||
39
backend/src/Administration/Domain/Model/User/Email.php
Normal file
39
backend/src/Administration/Domain/Model/User/Email.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use App\Administration\Domain\Exception\EmailInvalideException;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
/**
|
||||
* Value Object représentant une adresse email valide.
|
||||
*
|
||||
* Note: Les property hooks PHP 8.5 ne sont pas compatibles avec readonly.
|
||||
* La validation reste dans le constructeur pour préserver l'immutabilité du Value Object.
|
||||
*/
|
||||
final readonly class Email
|
||||
{
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw EmailInvalideException::pourAdresse($value);
|
||||
}
|
||||
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return strtolower($this->value) === strtolower($other->value);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
74
backend/src/Administration/Domain/Model/User/Role.php
Normal file
74
backend/src/Administration/Domain/Model/User/Role.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Enum représentant les rôles utilisateur dans Classeo.
|
||||
*
|
||||
* Hiérarchie RBAC :
|
||||
* - ROLE_SUPER_ADMIN → Accès à tous les établissements
|
||||
* - ROLE_ADMIN → Direction d'un établissement
|
||||
* - ROLE_PROF → Enseignant
|
||||
* - ROLE_VIE_SCOLAIRE → Personnel vie scolaire
|
||||
* - ROLE_SECRETARIAT → Personnel administratif
|
||||
* - ROLE_PARENT → Parent d'élève
|
||||
* - ROLE_ELEVE → Élève
|
||||
*/
|
||||
enum Role: string
|
||||
{
|
||||
case SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
|
||||
case ADMIN = 'ROLE_ADMIN';
|
||||
case PROF = 'ROLE_PROF';
|
||||
case VIE_SCOLAIRE = 'ROLE_VIE_SCOLAIRE';
|
||||
case SECRETARIAT = 'ROLE_SECRETARIAT';
|
||||
case PARENT = 'ROLE_PARENT';
|
||||
case ELEVE = 'ROLE_ELEVE';
|
||||
|
||||
/**
|
||||
* Vérifie si ce rôle inclut implicitement un autre rôle (hiérarchie).
|
||||
*/
|
||||
public function inclut(Role $autre): bool
|
||||
{
|
||||
$hierarchie = [
|
||||
self::SUPER_ADMIN->value => [
|
||||
self::ADMIN, self::PROF, self::VIE_SCOLAIRE,
|
||||
self::SECRETARIAT, self::PARENT, self::ELEVE,
|
||||
],
|
||||
self::ADMIN->value => [
|
||||
self::PROF, self::VIE_SCOLAIRE, self::SECRETARIAT,
|
||||
],
|
||||
];
|
||||
|
||||
$rolesInclus = $hierarchie[$this->value] ?? [];
|
||||
|
||||
return in_array($autre, $rolesInclus, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le libellé français du rôle.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SUPER_ADMIN => 'Super Administrateur',
|
||||
self::ADMIN => 'Directeur',
|
||||
self::PROF => 'Enseignant',
|
||||
self::VIE_SCOLAIRE => 'Vie Scolaire',
|
||||
self::SECRETARIAT => 'Secrétariat',
|
||||
self::PARENT => 'Parent',
|
||||
self::ELEVE => 'Élève',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce rôle nécessite un consentement parental potentiel.
|
||||
*/
|
||||
public function peutEtreMineur(): bool
|
||||
{
|
||||
return $this === self::ELEVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
/**
|
||||
* Enum représentant le statut d'activation d'un compte utilisateur.
|
||||
*/
|
||||
enum StatutCompte: string
|
||||
{
|
||||
case EN_ATTENTE = 'pending'; // Compte créé, en attente d'activation
|
||||
case CONSENTEMENT_REQUIS = 'consent'; // Mineur < 15 ans, en attente consentement parental
|
||||
case ACTIF = 'active'; // Compte activé et utilisable
|
||||
case SUSPENDU = 'suspended'; // Compte temporairement désactivé
|
||||
case ARCHIVE = 'archived'; // Compte archivé (fin de scolarité)
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut se connecter avec ce statut.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
return $this === self::ACTIF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut activer son compte.
|
||||
*/
|
||||
public function peutActiver(): bool
|
||||
{
|
||||
return $this === self::EN_ATTENTE;
|
||||
}
|
||||
}
|
||||
173
backend/src/Administration/Domain/Model/User/User.php
Normal file
173
backend/src/Administration/Domain/Model/User/User.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Event\CompteCreated;
|
||||
use App\Administration\Domain\Exception\CompteNonActivableException;
|
||||
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un utilisateur dans Classeo.
|
||||
*
|
||||
* Un utilisateur appartient à un établissement (tenant) et possède un rôle.
|
||||
* Le cycle de vie du compte passe par plusieurs statuts : création → activation.
|
||||
* Les mineurs (< 15 ans) nécessitent un consentement parental avant activation.
|
||||
*/
|
||||
final class User extends AggregateRoot
|
||||
{
|
||||
public private(set) ?string $hashedPassword = null;
|
||||
public private(set) ?DateTimeImmutable $activatedAt = null;
|
||||
public private(set) ?ConsentementParental $consentementParental = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) UserId $id,
|
||||
public private(set) Email $email,
|
||||
public private(set) Role $role,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) string $schoolName,
|
||||
public private(set) StatutCompte $statut,
|
||||
public private(set) ?DateTimeImmutable $dateNaissance,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau compte utilisateur en attente d'activation.
|
||||
*/
|
||||
public static function creer(
|
||||
Email $email,
|
||||
Role $role,
|
||||
TenantId $tenantId,
|
||||
string $schoolName,
|
||||
?DateTimeImmutable $dateNaissance,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
$user = new self(
|
||||
id: UserId::generate(),
|
||||
email: $email,
|
||||
role: $role,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
statut: StatutCompte::EN_ATTENTE,
|
||||
dateNaissance: $dateNaissance,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$user->recordEvent(new CompteCreated(
|
||||
userId: $user->id,
|
||||
email: (string) $user->email,
|
||||
role: $user->role->value,
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active le compte avec le mot de passe hashé.
|
||||
*
|
||||
* @throws CompteNonActivableException si le compte ne peut pas être activé
|
||||
*/
|
||||
public function activer(
|
||||
string $hashedPassword,
|
||||
DateTimeImmutable $at,
|
||||
ConsentementParentalPolicy $consentementPolicy,
|
||||
): void {
|
||||
if (!$this->statut->peutActiver()) {
|
||||
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
|
||||
}
|
||||
|
||||
// Vérifier si le consentement parental est requis
|
||||
if ($consentementPolicy->estRequis($this->dateNaissance)) {
|
||||
if ($this->consentementParental === null) {
|
||||
throw CompteNonActivableException::carConsentementManquant($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
$this->hashedPassword = $hashedPassword;
|
||||
$this->statut = StatutCompte::ACTIF;
|
||||
$this->activatedAt = $at;
|
||||
|
||||
$this->recordEvent(new CompteActive(
|
||||
userId: (string) $this->id,
|
||||
email: (string) $this->email,
|
||||
tenantId: $this->tenantId,
|
||||
role: $this->role->value,
|
||||
occurredOn: $at,
|
||||
aggregateId: $this->id->value,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le consentement parental donné par le parent.
|
||||
*/
|
||||
public function enregistrerConsentementParental(ConsentementParental $consentement): void
|
||||
{
|
||||
$this->consentementParental = $consentement;
|
||||
|
||||
// Si le compte était en attente de consentement, passer en attente d'activation
|
||||
if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
|
||||
$this->statut = StatutCompte::EN_ATTENTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si cet utilisateur est mineur et nécessite un consentement parental.
|
||||
*/
|
||||
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
|
||||
{
|
||||
return $policy->estRequis($this->dateNaissance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le compte est actif et peut se connecter.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
return $this->statut->peutSeConnecter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un User depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage par l'Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
UserId $id,
|
||||
Email $email,
|
||||
Role $role,
|
||||
TenantId $tenantId,
|
||||
string $schoolName,
|
||||
StatutCompte $statut,
|
||||
?DateTimeImmutable $dateNaissance,
|
||||
DateTimeImmutable $createdAt,
|
||||
?string $hashedPassword,
|
||||
?DateTimeImmutable $activatedAt,
|
||||
?ConsentementParental $consentementParental,
|
||||
): self {
|
||||
$user = new self(
|
||||
id: $id,
|
||||
email: $email,
|
||||
role: $role,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
statut: $statut,
|
||||
dateNaissance: $dateNaissance,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$user->hashedPassword = $hashedPassword;
|
||||
$user->activatedAt = $activatedAt;
|
||||
$user->consentementParental = $consentementParental;
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
11
backend/src/Administration/Domain/Model/User/UserId.php
Normal file
11
backend/src/Administration/Domain/Model/User/UserId.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class UserId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Policy;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Policy déterminant si le consentement parental est requis.
|
||||
*
|
||||
* Conformément au RGPD (NFR-C1), le consentement parental est obligatoire
|
||||
* pour les utilisateurs de moins de 15 ans.
|
||||
*/
|
||||
final readonly class ConsentementParentalPolicy
|
||||
{
|
||||
private const int AGE_MAJORITE_NUMERIQUE = 15;
|
||||
|
||||
public function __construct(
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le consentement parental est requis pour un utilisateur
|
||||
* né à la date spécifiée.
|
||||
*/
|
||||
public function estRequis(?DateTimeImmutable $dateNaissance): bool
|
||||
{
|
||||
if ($dateNaissance === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->calculerAge($dateNaissance) < self::AGE_MAJORITE_NUMERIQUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'âge en années à partir de la date de naissance.
|
||||
*/
|
||||
private function calculerAge(DateTimeImmutable $dateNaissance): int
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
$interval = $now->diff($dateNaissance);
|
||||
|
||||
return $interval->y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
|
||||
interface ActivationTokenRepository
|
||||
{
|
||||
public function save(ActivationToken $token): void;
|
||||
|
||||
/**
|
||||
* Find a token by its unique token value.
|
||||
*/
|
||||
public function findByTokenValue(string $tokenValue): ?ActivationToken;
|
||||
|
||||
/**
|
||||
* Get a token by its ID.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\ActivationTokenNotFoundException if token does not exist
|
||||
*/
|
||||
public function get(ActivationTokenId $id): ActivationToken;
|
||||
|
||||
/**
|
||||
* Delete a token (after use or for cleanup).
|
||||
*/
|
||||
public function delete(ActivationTokenId $id): void;
|
||||
|
||||
/**
|
||||
* Delete a token by its token value.
|
||||
*/
|
||||
public function deleteByTokenValue(string $tokenValue): void;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
|
||||
interface UserRepository
|
||||
{
|
||||
public function save(User $user): void;
|
||||
|
||||
/**
|
||||
* @throws \App\Administration\Domain\Exception\UserNotFoundException
|
||||
*/
|
||||
public function get(UserId $id): User;
|
||||
|
||||
public function findByEmail(Email $email): ?User;
|
||||
}
|
||||
Reference in New Issue
Block a user