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:
2026-01-31 18:00:43 +01:00
parent 1fd256346a
commit c5e6c1d810
69 changed files with 5173 additions and 13 deletions

View File

@@ -128,11 +128,74 @@ jobs:
- name: Run unit tests
run: pnpm run test
# =============================================================================
# E2E Tests - Playwright with Docker backend
# =============================================================================
test-e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
working-directory: frontend
run: pnpm exec playwright install --with-deps
- name: Build and start backend services
run: |
# Build images first (with Docker layer caching)
docker compose build php
# Start services (includes db, redis, rabbitmq dependencies)
docker compose up -d php
timeout-minutes: 10
- name: Wait for backend to be ready
run: |
echo "Waiting for backend to be ready (composer install + app startup)..."
# Wait up to 5 minutes for the backend to respond
timeout 300 bash -c 'until curl -sf http://localhost:18000/api > /dev/null 2>&1; do
echo "Waiting for backend..."
sleep 5
done'
echo "Backend is ready!"
- name: Show backend logs on failure
if: failure()
run: docker compose logs php
- name: Run E2E tests
working-directory: frontend
run: pnpm run test:e2e
env:
# Frontend serves on 4173 (preview mode), backend on 18000 (Docker)
PUBLIC_API_PORT: "18000"
PUBLIC_API_URL: http://localhost:18000/api
- name: Upload Playwright report
uses: actions/upload-artifact@v4
@@ -142,6 +205,10 @@ jobs:
path: frontend/playwright-report/
retention-days: 7
- name: Stop backend services
if: always()
run: docker compose down
# =============================================================================
# Naming Conventions Check
# =============================================================================
@@ -161,7 +228,7 @@ jobs:
build:
name: Build Check
runs-on: ubuntu-latest
needs: [test-backend, test-frontend]
needs: [test-backend, test-frontend, test-e2e]
steps:
- uses: actions/checkout@v4

View File

@@ -1,4 +1,4 @@
.PHONY: help up down restart rebuild logs ps test lint phpstan arch cs-fix warmup frontend-lint frontend-test e2e clean
.PHONY: help up down restart rebuild logs ps test lint phpstan arch cs-fix warmup frontend-lint frontend-test e2e clean shell bash console
# Default target
help:
@@ -14,6 +14,12 @@ help:
@echo " make ps - Statut des services"
@echo " make clean - Supprimer volumes et images"
@echo ""
@echo "Shell:"
@echo " make shell - Shell bash dans le container PHP"
@echo " make bash - Alias pour make shell"
@echo " make console - Console Symfony (ex: make console c='debug:router')"
@echo " make shell-frontend - Shell dans le container frontend"
@echo ""
@echo "Backend:"
@echo " make phpstan - Analyse statique PHPStan"
@echo " make arch - Tests d'architecture (PHPat)"
@@ -61,6 +67,21 @@ ps:
clean:
docker compose down -v --rmi local
# =============================================================================
# Shell
# =============================================================================
shell:
docker compose exec php sh
bash: shell
console:
docker compose exec php php bin/console $(c)
shell-frontend:
docker compose exec frontend sh
# =============================================================================
# Backend
# =============================================================================
@@ -119,3 +140,16 @@ check-naming:
check-tenants:
./scripts/check-tenants.sh
# =============================================================================
# Dev helpers
# =============================================================================
# Creer un token d'activation de test
# Usage: make token [email=user@test.com] [role=PARENT] [minor=1]
token:
docker compose exec php php bin/console app:dev:create-test-activation-token \
$(if $(email),--email=$(email),) \
$(if $(role),--role=$(role),) \
$(if $(minor),--minor,) \
--base-url=http://localhost:5174

View File

@@ -63,3 +63,12 @@ DEFAULT_URI=http://localhost
# Base domain for tenant resolution (e.g., classeo.fr, classeo.local)
TENANT_BASE_DOMAIN=classeo.local
###< multi-tenant ###
###> app ###
# Frontend URL for emails and links
APP_URL=http://localhost:5173
###< app ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

View File

@@ -74,7 +74,10 @@ RUN echo "xdebug.mode=develop,debug,coverage" >> "$PHP_INI_DIR/conf.d/xdebug.ini
# Caddy config for FrankenPHP
ENV SERVER_NAME=:8000
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
# In dev mode, we do NOT use worker mode to enable automatic file reloading
# Each request loads PHP fresh, so code changes are picked up immediately
# Worker mode is only used in production for performance
ENV FRANKENPHP_CONFIG=""
# Entrypoint: detect host UID/GID and run as matching user
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)

View File

@@ -16,6 +16,7 @@
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"ramsey/uuid": "^4.7",
"symfony/amqp-messenger": "^8.0",
"symfony/asset": "^8.0",
@@ -24,6 +25,7 @@
"symfony/dotenv": "^8.0",
"symfony/flex": "^2",
"symfony/framework-bundle": "^8.0",
"symfony/mailer": "8.0.*",
"symfony/messenger": "^8.0",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "^8.0",

387
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5db4139b65c041189bc59e0582d6f82d",
"content-hash": "e5abd2128a53127e2298b296ed587025",
"packages": [
{
"name": "api-platform/core",
@@ -1390,6 +1390,73 @@
},
"time": "2025-10-26T09:35:14+00:00"
},
{
"name": "egulias/email-validator",
"version": "4.0.4",
"source": {
"type": "git",
"url": "https://github.com/egulias/EmailValidator.git",
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
"shasum": ""
},
"require": {
"doctrine/lexer": "^2.0 || ^3.0",
"php": ">=8.1",
"symfony/polyfill-intl-idn": "^1.26"
},
"require-dev": {
"phpunit/phpunit": "^10.2",
"vimeo/psalm": "^5.12"
},
"suggest": {
"ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Egulias\\EmailValidator\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Eduardo Gulias Davis"
}
],
"description": "A library for validating emails against several RFCs",
"homepage": "https://github.com/egulias/EmailValidator",
"keywords": [
"email",
"emailvalidation",
"emailvalidator",
"validation",
"validator"
],
"support": {
"issues": "https://github.com/egulias/EmailValidator/issues",
"source": "https://github.com/egulias/EmailValidator/tree/4.0.4"
},
"funding": [
{
"url": "https://github.com/egulias",
"type": "github"
}
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -1682,6 +1749,71 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "nelmio/cors-bundle",
"version": "2.6.1",
"source": {
"type": "git",
"url": "https://github.com/nelmio/NelmioCorsBundle.git",
"reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c",
"reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c",
"shasum": ""
},
"require": {
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11.5",
"phpstan/phpstan-deprecation-rules": "^1.2.0",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-symfony": "^1.4.4",
"phpunit/phpunit": "^8"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Nelmio\\CorsBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nelmio",
"homepage": "http://nelm.io"
},
{
"name": "Symfony Community",
"homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors"
}
],
"description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application",
"keywords": [
"api",
"cors",
"crossdomain"
],
"support": {
"issues": "https://github.com/nelmio/NelmioCorsBundle/issues",
"source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.1"
},
"time": "2026-01-12T15:59:08+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@@ -3883,6 +4015,86 @@
],
"time": "2026-01-28T10:46:31+00:00"
},
{
"name": "symfony/mailer",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
"reference": "a074d353f5b5a81d356652e8a2034fdd0501420b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mailer/zipball/a074d353f5b5a81d356652e8a2034fdd0501420b",
"reference": "a074d353f5b5a81d356652e8a2034fdd0501420b",
"shasum": ""
},
"require": {
"egulias/email-validator": "^2.1.10|^3|^4",
"php": ">=8.4",
"psr/event-dispatcher": "^1",
"psr/log": "^1|^2|^3",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"symfony/http-client-contracts": "<2.5"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/twig-bridge": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mailer\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/mailer/tree/v8.0.4"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-08T08:40:07+00:00"
},
{
"name": "symfony/messenger",
"version": "v8.0.4",
@@ -3973,6 +4185,92 @@
],
"time": "2026-01-08T22:36:47+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/543d01b6ee4b8eb80ce9349186ad530eb8704252",
"reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<5.2|>=6",
"phpdocumentor/type-resolver": "<1.5.1"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.5"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-27T09:06:10+00:00"
},
{
"name": "symfony/monolog-bridge",
"version": "v8.0.4",
@@ -4284,6 +4582,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",

View File

@@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
@@ -15,4 +13,5 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
];

View File

@@ -3,6 +3,17 @@ framework:
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: classeo/backend
pools:
# Pool dédié aux tokens d'activation (7 jours TTL)
activation_tokens.cache:
adapter: cache.adapter.filesystem
default_lifetime: 604800 # 7 jours
# Pool dédié aux utilisateurs (pas de TTL - données persistantes)
users.cache:
adapter: cache.adapter.filesystem
default_lifetime: 0 # Pas d'expiration
when@prod:
framework:
cache:
@@ -11,3 +22,11 @@ when@prod:
adapter: cache.adapter.system
doctrine.result_cache_pool:
adapter: cache.adapter.system
activation_tokens.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 604800 # 7 jours
users.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 0 # Pas d'expiration

View File

@@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'

View File

@@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View File

@@ -2,6 +2,9 @@ security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# Named hasher for domain services (decoupled from User entity)
common:
algorithm: auto
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
@@ -16,6 +19,10 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api_public:
pattern: ^/api/(activation-tokens|activate|login|docs)(/|$)
stateless: true
security: false
api:
pattern: ^/api
stateless: true
@@ -29,6 +36,8 @@ security:
access_control:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api/activation-tokens, roles: PUBLIC_ACCESS }
- { path: ^/api/activate, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:

View File

@@ -5,12 +5,21 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
tenant.base_domain: '%env(TENANT_BASE_DOMAIN)%'
app.url: '%env(APP_URL)%'
services:
# default configuration for services in this file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
# Bind activation tokens cache pool (7-day TTL)
Psr\Cache\CacheItemPoolInterface $activationTokensCache: '@activation_tokens.cache'
# Bind users cache pool (no TTL - persistent data)
Psr\Cache\CacheItemPoolInterface $usersCache: '@users.cache'
# Bind named message buses
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
@@ -43,3 +52,27 @@ services:
App\Shared\Infrastructure\Tenant\Command\TenantMigrateCommand:
arguments:
$projectDir: '%kernel.project_dir%'
# Administration services
# Bind Repository interfaces to their implementations
App\Administration\Domain\Repository\ActivationTokenRepository:
alias: App\Administration\Infrastructure\Persistence\Redis\RedisActivationTokenRepository
App\Administration\Domain\Repository\UserRepository:
alias: App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository
App\Administration\Application\Port\PasswordHasher:
alias: App\Administration\Infrastructure\Security\SymfonyPasswordHasher
# Clock interface binding
App\Shared\Domain\Clock:
alias: App\Shared\Infrastructure\Clock\SystemClock
# Domain policies (need explicit registration as Domain is excluded from autowiring)
App\Administration\Domain\Policy\ConsentementParentalPolicy:
autowire: true
# Email handlers
App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler:
arguments:
$appUrl: '%app.url%'

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateAccount;
/**
* Command to activate a user account using an activation token.
*
* This command is dispatched when a user clicks their activation link
* and submits a valid password.
*/
final readonly class ActivateAccountCommand
{
public function __construct(
public string $tokenValue,
public string $password,
) {
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateAccount;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ActivateAccountHandler
{
public function __construct(
private ActivationTokenRepository $tokenRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
) {
}
/**
* @throws ActivationTokenNotFoundException if token does not exist
* @throws \App\Administration\Domain\Exception\ActivationTokenExpiredException if token is expired
* @throws \App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException if token was already used
*/
public function __invoke(ActivateAccountCommand $command): ActivateAccountResult
{
$token = $this->tokenRepository->findByTokenValue($command->tokenValue);
if ($token === null) {
throw ActivationTokenNotFoundException::withTokenValue($command->tokenValue);
}
$now = $this->clock->now();
// Validate token can be used (throws if expired or already used)
// Note: Token is NOT marked as used here - that's deferred to the processor
// after successful user activation, so failed activations don't burn the token
$token->validateForUse($now);
// Hash the password for User model
$hashedPassword = $this->passwordHasher->hash($command->password);
return new ActivateAccountResult(
userId: $token->userId,
email: $token->email,
tenantId: $token->tenantId,
role: $token->role,
hashedPassword: $hashedPassword,
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateAccount;
use App\Shared\Infrastructure\Tenant\TenantId;
/**
* Result of the ActivateAccountCommand execution.
*
* Contains the information needed to complete the activation process,
* including the hashed password to be stored on the User aggregate.
*/
final readonly class ActivateAccountResult
{
public function __construct(
public string $userId,
public string $email,
public TenantId $tenantId,
public string $role,
public string $hashedPassword,
) {
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
/**
* Port interface for password hashing operations.
*
* This abstracts the password hashing mechanism, allowing the Application
* layer to remain independent of the specific hashing implementation
* (e.g., Symfony PasswordHasher with Argon2id).
*/
interface PasswordHasher
{
/**
* Hash a plain text password.
*/
public function hash(string $plainPassword): string;
/**
* Verify a plain password against a hash.
*/
public function verify(string $hashedPassword, string $plainPassword): bool;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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
{
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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
{
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Exception\CompteNonActivableException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountOutput;
use App\Shared\Domain\Clock;
use Override;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* API Platform processor for account activation.
*
* @implements ProcessorInterface<ActivateAccountInput, ActivateAccountOutput>
*/
final readonly class ActivateAccountProcessor implements ProcessorInterface
{
public function __construct(
private ActivateAccountHandler $handler,
private UserRepository $userRepository,
private ActivationTokenRepository $tokenRepository,
private ConsentementParentalPolicy $consentementPolicy,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
/**
* @param ActivateAccountInput $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ActivateAccountOutput
{
$command = new ActivateAccountCommand(
tokenValue: $data->tokenValue,
password: $data->password,
);
try {
$result = ($this->handler)($command);
} catch (ActivationTokenNotFoundException) {
throw new NotFoundHttpException('Token d\'activation invalide ou introuvable.');
} catch (ActivationTokenExpiredException) {
throw new BadRequestHttpException('Le token d\'activation a expiré. Veuillez contacter votre établissement pour obtenir un nouveau lien.');
} catch (ActivationTokenAlreadyUsedException) {
throw new BadRequestHttpException('Ce token d\'activation a déjà été utilisé.');
}
// Activate the User account
try {
$user = $this->userRepository->get(UserId::fromString($result->userId));
$user->activer(
hashedPassword: $result->hashedPassword,
at: $this->clock->now(),
consentementPolicy: $this->consentementPolicy,
);
$this->userRepository->save($user);
// Publish domain events recorded on the User aggregate
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Delete token only after successful user activation
// This ensures failed activations (e.g., missing parental consent) don't burn the token
$this->tokenRepository->deleteByTokenValue($data->tokenValue);
} catch (UserNotFoundException) {
throw new NotFoundHttpException('Utilisateur introuvable.');
} catch (CompteNonActivableException $e) {
throw new BadRequestHttpException($e->getMessage());
}
return new ActivateAccountOutput(
userId: $result->userId,
email: $result->email,
role: $result->role,
);
}
}

View File

@@ -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\Domain\Model\User\Role;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Administration\Infrastructure\Api\Resource\ActivationTokenInfo;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use Override;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* API Platform provider for activation token information.
*
* @implements ProviderInterface<ActivationTokenInfo>
*/
final readonly class ActivationTokenInfoProvider implements ProviderInterface
{
public function __construct(
private ActivationTokenRepository $tokenRepository,
private Clock $clock,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ActivationTokenInfo
{
/** @var string $tokenValue */
$tokenValue = $uriVariables['tokenValue'] ?? '';
$token = $this->tokenRepository->findByTokenValue($tokenValue);
if ($token === null) {
throw new NotFoundHttpException('Token d\'activation introuvable.');
}
if ($token->isUsed()) {
throw new NotFoundHttpException('Ce token d\'activation a déjà été utilisé.');
}
return new ActivationTokenInfo(
tokenValue: $token->tokenValue,
email: $token->email,
role: $this->translateRole($token->role),
schoolName: $token->schoolName,
isExpired: $token->isExpired($this->clock->now()),
expiresAt: $token->expiresAt->format(DateTimeImmutable::ATOM),
);
}
private function translateRole(string $role): string
{
$roleEnum = Role::tryFrom($role);
return $roleEnum?->label() ?? $role;
}
}

View File

@@ -0,0 +1,49 @@
<?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\ActivateAccountProcessor;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource for account activation.
*
* This endpoint accepts a token value and new password to activate a user account.
*/
#[ApiResource(
shortName: 'AccountActivation',
operations: [
new Post(
uriTemplate: '/activate',
processor: ActivateAccountProcessor::class,
output: ActivateAccountOutput::class,
validationContext: ['groups' => ['Default', 'activate']],
name: 'activate_account',
),
],
)]
final class ActivateAccountInput
{
#[Assert\NotBlank(message: 'Le token d\'activation est requis.')]
#[Assert\Uuid(message: 'Le token d\'activation doit être un UUID valide.')]
public string $tokenValue = '';
#[Assert\NotBlank(message: 'Le mot de passe est requis.')]
#[Assert\Length(
min: 8,
minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères.',
)]
#[Assert\Regex(
pattern: '/[A-Z]/',
message: 'Le mot de passe doit contenir au moins une majuscule.',
)]
#[Assert\Regex(
pattern: '/[0-9]/',
message: 'Le mot de passe doit contenir au moins un chiffre.',
)]
public string $password = '';
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
/**
* API Output for successful account activation.
*/
final readonly class ActivateAccountOutput
{
public function __construct(
public string $userId,
public string $email,
public string $role,
public string $message = 'Compte activé avec succès.',
) {
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Administration\Infrastructure\Api\Provider\ActivationTokenInfoProvider;
/**
* API Resource for retrieving activation token information.
*
* Used by the frontend to display the establishment name and role
* before the user submits their password.
*/
#[ApiResource(
shortName: 'ActivationTokenInfo',
operations: [
new Get(
uriTemplate: '/activation-tokens/{tokenValue}',
provider: ActivationTokenInfoProvider::class,
name: 'get_activation_token_info',
),
],
)]
final readonly class ActivationTokenInfo
{
public function __construct(
public string $tokenValue,
public string $email,
public string $role,
public string $schoolName,
public bool $isExpired,
public string $expiresAt,
) {
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:dev:create-test-activation-token',
description: 'Creates a test user and activation token for development',
)]
final class CreateTestActivationTokenCommand extends Command
{
public function __construct(
private readonly ActivationTokenRepository $activationTokenRepository,
private readonly UserRepository $userRepository,
private readonly Clock $clock,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'test@example.com')
->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT')
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
->addOption('minor', null, InputOption::VALUE_NONE, 'Create a minor user (requires parental consent)')
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5173');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var string $email */
$email = $input->getOption('email');
/** @var string $roleOption */
$roleOption = $input->getOption('role');
$roleInput = strtoupper($roleOption);
/** @var string $schoolName */
$schoolName = $input->getOption('school');
$isMinor = $input->getOption('minor');
/** @var string $baseUrlOption */
$baseUrlOption = $input->getOption('base-url');
$baseUrl = rtrim($baseUrlOption, '/');
// Convert short role name to full Symfony role format
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;
$role = Role::tryFrom($roleName);
if ($role === null) {
$validRoles = array_map(static fn (Role $r) => str_replace('ROLE_', '', $r->value), Role::cases());
$io->error(sprintf(
'Invalid role "%s". Valid roles: %s',
$roleInput,
implode(', ', $validRoles)
));
return Command::FAILURE;
}
$now = $this->clock->now();
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440001');
// Create user
$dateNaissance = $isMinor
? $now->modify('-13 years') // 13 ans = mineur
: null;
$user = User::creer(
email: new Email($email),
role: $role,
tenantId: $tenantId,
schoolName: $schoolName,
dateNaissance: $dateNaissance,
createdAt: $now,
);
$this->userRepository->save($user);
// Create activation token
$token = ActivationToken::generate(
userId: (string) $user->id,
email: $email,
tenantId: $tenantId,
role: $role->value,
schoolName: $schoolName,
createdAt: $now,
);
$this->activationTokenRepository->save($token);
$activationUrl = sprintf('%s/activate/%s', $baseUrl, $token->tokenValue);
$io->success('Test activation token created successfully!');
$io->table(
['Property', 'Value'],
[
['User ID', (string) $user->id],
['Email', $email],
['Role', $role->value],
['School', $schoolName],
['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'],
['Token', $token->tokenValue],
['Expires', $token->expiresAt->format('Y-m-d H:i:s')],
]
);
$io->writeln('');
$io->writeln(sprintf('<info>Activation URL:</info> <href=%s>%s</>', $activationUrl, $activationUrl));
$io->writeln('');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Model\User\Role;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Twig\Environment;
/**
* Sends a confirmation email when an account is activated.
*
* This handler listens for CompteActive events and sends an email
* to the user confirming their account activation.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class SendActivationConfirmationHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private string $appUrl,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(CompteActive $event): void
{
$roleEnum = Role::tryFrom($event->role);
$roleLabel = $roleEnum?->label() ?? $event->role;
$html = $this->twig->render('emails/activation_confirmation.html.twig', [
'email' => $event->email,
'role' => $roleLabel,
'loginUrl' => rtrim($this->appUrl, '/') . '/login',
]);
$email = (new Email())
->from($this->fromEmail)
->to($event->email)
->subject('Votre compte Classeo est activé')
->html($html);
$this->mailer->send($email);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Cache;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
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\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Psr\Cache\CacheItemPoolInterface;
/**
* Cache-based UserRepository for development and testing.
* Uses PSR-6 cache (filesystem in dev, Redis in prod).
*
* Note: Uses a dedicated users.cache pool with no TTL to ensure
* user records don't expire (unlike activation tokens which expire after 7 days).
*/
final readonly class CacheUserRepository implements UserRepository
{
private const string KEY_PREFIX = 'user:';
private const string EMAIL_INDEX_PREFIX = 'user_email:';
public function __construct(
private CacheItemPoolInterface $usersCache,
) {
}
public function save(User $user): void
{
// Save user data
$item = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
$item->set($this->serialize($user));
$this->usersCache->save($item);
// Save email index for lookup
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($user->email));
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
}
public function findById(UserId $id): ?User
{
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
if (!$item->isHit()) {
return null;
}
/** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
public function findByEmail(Email $email): ?User
{
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($email));
if (!$emailItem->isHit()) {
return null;
}
/** @var string $userId */
$userId = $emailItem->get();
return $this->findById(UserId::fromString($userId));
}
public function get(UserId $id): User
{
$user = $this->findById($id);
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
/**
* @return array<string, mixed>
*/
private function serialize(User $user): array
{
$consentement = $user->consentementParental;
return [
'id' => (string) $user->id,
'email' => (string) $user->email,
'role' => $user->role->value,
'tenant_id' => (string) $user->tenantId,
'school_name' => $user->schoolName,
'statut' => $user->statut->value,
'hashed_password' => $user->hashedPassword,
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
'created_at' => $user->createdAt->format('c'),
'activated_at' => $user->activatedAt?->format('c'),
'consentement_parental' => $consentement !== null ? [
'parent_id' => $consentement->parentId,
'eleve_id' => $consentement->eleveId,
'date_consentement' => $consentement->dateConsentement->format('c'),
'ip_address' => $consentement->ipAddress,
] : null,
];
}
/**
* @param array{
* id: string,
* email: string,
* role: string,
* tenant_id: string,
* school_name: string,
* statut: string,
* hashed_password: string|null,
* date_naissance: string|null,
* created_at: string,
* activated_at: string|null,
* consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null
* } $data
*/
private function deserialize(array $data): User
{
$consentement = null;
if ($data['consentement_parental'] !== null) {
$consentementData = $data['consentement_parental'];
$consentement = ConsentementParental::accorder(
parentId: $consentementData['parent_id'],
eleveId: $consentementData['eleve_id'],
at: new DateTimeImmutable($consentementData['date_consentement']),
ipAddress: $consentementData['ip_address'],
);
}
return User::reconstitute(
id: UserId::fromString($data['id']),
email: new Email($data['email']),
role: Role::from($data['role']),
tenantId: TenantId::fromString($data['tenant_id']),
schoolName: $data['school_name'],
statut: StatutCompte::from($data['statut']),
dateNaissance: $data['date_naissance'] !== null ? new DateTimeImmutable($data['date_naissance']) : null,
createdAt: new DateTimeImmutable($data['created_at']),
hashedPassword: $data['hashed_password'],
activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null,
consentementParental: $consentement,
);
}
private function normalizeEmail(Email $email): string
{
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use Override;
final class InMemoryActivationTokenRepository implements ActivationTokenRepository
{
/** @var array<string, ActivationToken> Indexed by token value */
private array $byTokenValue = [];
/** @var array<string, string> Maps ID to token value */
private array $idToTokenValue = [];
#[Override]
public function save(ActivationToken $token): void
{
$this->byTokenValue[$token->tokenValue] = $token;
$this->idToTokenValue[(string) $token->id] = $token->tokenValue;
}
#[Override]
public function findByTokenValue(string $tokenValue): ?ActivationToken
{
return $this->byTokenValue[$tokenValue] ?? null;
}
#[Override]
public function get(ActivationTokenId $id): ActivationToken
{
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
if ($tokenValue === null) {
throw ActivationTokenNotFoundException::withId($id);
}
$token = $this->byTokenValue[$tokenValue] ?? null;
if ($token === null) {
throw ActivationTokenNotFoundException::withId($id);
}
return $token;
}
#[Override]
public function delete(ActivationTokenId $id): void
{
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
if ($tokenValue !== null) {
unset($this->byTokenValue[$tokenValue]);
}
unset($this->idToTokenValue[(string) $id]);
}
#[Override]
public function deleteByTokenValue(string $tokenValue): void
{
$token = $this->byTokenValue[$tokenValue] ?? null;
if ($token !== null) {
unset($this->idToTokenValue[(string) $token->id]);
}
unset($this->byTokenValue[$tokenValue]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use Override;
final class InMemoryUserRepository implements UserRepository
{
/** @var array<string, User> Indexed by ID */
private array $byId = [];
/** @var array<string, User> Indexed by email (lowercase) */
private array $byEmail = [];
#[Override]
public function save(User $user): void
{
$this->byId[(string) $user->id] = $user;
$this->byEmail[strtolower((string) $user->email)] = $user;
}
#[Override]
public function get(UserId $id): User
{
$user = $this->byId[(string) $id] ?? null;
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
#[Override]
public function findByEmail(Email $email): ?User
{
return $this->byEmail[strtolower((string) $email)] ?? null;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Redis;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
final readonly class RedisActivationTokenRepository implements ActivationTokenRepository
{
private const string KEY_PREFIX = 'activation:';
private const int TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
public function __construct(
private CacheItemPoolInterface $activationTokensCache,
) {
}
#[Override]
public function save(ActivationToken $token): void
{
// Store by token value for lookup during activation
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue);
$item->set($this->serialize($token));
$item->expiresAfter(self::TTL_SECONDS);
$this->activationTokensCache->save($item);
// Also store by ID for direct access
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id);
$idItem->set($token->tokenValue);
$idItem->expiresAfter(self::TTL_SECONDS);
$this->activationTokensCache->save($idItem);
}
#[Override]
public function findByTokenValue(string $tokenValue): ?ActivationToken
{
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $tokenValue);
if (!$item->isHit()) {
return null;
}
/** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
#[Override]
public function get(ActivationTokenId $id): ActivationToken
{
// First get the token value from the ID index
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
if (!$idItem->isHit()) {
throw ActivationTokenNotFoundException::withId($id);
}
/** @var string $tokenValue */
$tokenValue = $idItem->get();
$token = $this->findByTokenValue($tokenValue);
if ($token === null) {
throw ActivationTokenNotFoundException::withId($id);
}
return $token;
}
#[Override]
public function delete(ActivationTokenId $id): void
{
// Get token value first
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
if ($idItem->isHit()) {
/** @var string $tokenValue */
$tokenValue = $idItem->get();
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
}
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id);
}
#[Override]
public function deleteByTokenValue(string $tokenValue): void
{
$token = $this->findByTokenValue($tokenValue);
if ($token !== null) {
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id);
}
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
}
/**
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null}
*/
private function serialize(ActivationToken $token): array
{
return [
'id' => (string) $token->id,
'token_value' => $token->tokenValue,
'user_id' => $token->userId,
'email' => $token->email,
'tenant_id' => (string) $token->tenantId,
'role' => $token->role,
'school_name' => $token->schoolName,
'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM),
'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM),
'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM),
];
}
/**
* @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data
*/
private function deserialize(array $data): ActivationToken
{
return ActivationToken::reconstitute(
id: ActivationTokenId::fromString($data['id']),
tokenValue: $data['token_value'],
userId: $data['user_id'],
email: $data['email'],
tenantId: TenantId::fromString($data['tenant_id']),
role: $data['role'],
schoolName: $data['school_name'],
createdAt: new DateTimeImmutable($data['created_at']),
expiresAt: new DateTimeImmutable($data['expires_at']),
usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null,
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Application\Port\PasswordHasher;
use Override;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
/**
* Symfony implementation of the PasswordHasher port.
*
* Uses Symfony's PasswordHasher component with Argon2id algorithm.
*/
final readonly class SymfonyPasswordHasher implements PasswordHasher
{
private const string HASHER_ID = 'common';
public function __construct(
private PasswordHasherFactoryInterface $hasherFactory,
) {
}
#[Override]
public function hash(string $plainPassword): string
{
return $this->hasherFactory
->getPasswordHasher(self::HASHER_ID)
->hash($plainPassword);
}
#[Override]
public function verify(string $hashedPassword, string $plainPassword): bool
{
return $this->hasherFactory
->getPasswordHasher(self::HASHER_ID)
->verify($hashedPassword, $plainPassword);
}
}

View File

@@ -31,6 +31,9 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
'/api/docs.json',
'/api/docs.jsonld',
'/api/contexts',
'/api/activation-tokens',
'/api/activate',
'/api/login',
'/_profiler',
'/_wdt',
'/_error',

View File

@@ -85,6 +85,18 @@
"config/packages/lexik_jwt_authentication.yaml"
]
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
@@ -166,6 +178,18 @@
".editorconfig"
]
},
"symfony/mailer": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.3",
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
},
"files": [
"config/packages/mailer.yaml"
]
},
"symfony/maker-bundle": {
"version": "1.65",
"recipe": {

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Compte activé - Classeo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 2px solid #4f46e5;
}
.header h1 {
color: #4f46e5;
margin: 0;
font-size: 28px;
}
.content {
padding: 30px 0;
}
.success-icon {
text-align: center;
padding: 20px;
}
.success-icon span {
display: inline-block;
width: 60px;
height: 60px;
background-color: #10b981;
border-radius: 50%;
line-height: 60px;
color: white;
font-size: 30px;
}
.info-box {
background-color: #f3f4f6;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.info-box p {
margin: 5px 0;
}
.button {
display: inline-block;
background-color: #4f46e5;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
}
.button:hover {
background-color: #4338ca;
}
.footer {
text-align: center;
padding: 20px 0;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1>Classeo</h1>
</div>
<div class="content">
<div class="success-icon">
<span>✓</span>
</div>
<h2 style="text-align: center;">Votre compte est activé !</h2>
<p>Bonjour,</p>
<p>Nous vous confirmons que votre compte Classeo a été activé avec succès.</p>
<div class="info-box">
<p><strong>Email :</strong> {{ email }}</p>
<p><strong>Rôle :</strong> {{ role }}</p>
</div>
<p>Vous pouvez maintenant vous connecter à Classeo pour accéder à toutes les fonctionnalités disponibles.</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ loginUrl }}" class="button">Se connecter</a>
</p>
<p><strong>Conseils de sécurité :</strong></p>
<ul>
<li>Ne partagez jamais votre mot de passe</li>
<li>Déconnectez-vous après utilisation sur un ordinateur partagé</li>
<li>Contactez votre établissement en cas de problème d'accès</li>
</ul>
</div>
<div class="footer">
<p>Cet email a été envoyé automatiquement par Classeo.</p>
<p>Si vous n'êtes pas à l'origine de cette action, veuillez contacter votre établissement.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ActivateAccount;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountResult;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ActivateAccountHandlerTest extends TestCase
{
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private const string ROLE = 'ROLE_PARENT';
private const string SCHOOL_NAME = 'École Alpha';
private const string PASSWORD = 'SecurePass123';
private const string HASHED_PASSWORD = '$argon2id$hashed_password';
private InMemoryActivationTokenRepository $tokenRepository;
private PasswordHasher $passwordHasher;
private Clock $clock;
private ActivateAccountHandler $handler;
protected function setUp(): void
{
$this->tokenRepository = new InMemoryActivationTokenRepository();
$this->passwordHasher = new class implements PasswordHasher {
#[Override]
public function hash(string $plainPassword): string
{
return '$argon2id$hashed_password';
}
#[Override]
public function verify(string $hashedPassword, string $plainPassword): bool
{
return true;
}
};
$this->clock = new class implements Clock {
public DateTimeImmutable $now;
public function __construct()
{
$this->now = new DateTimeImmutable('2026-01-16 10:00:00');
}
#[Override]
public function now(): DateTimeImmutable
{
return $this->now;
}
};
$this->handler = new ActivateAccountHandler(
$this->tokenRepository,
$this->passwordHasher,
$this->clock,
);
}
#[Test]
public function activateAccountSuccessfully(): void
{
$token = $this->createAndSaveToken();
$command = new ActivateAccountCommand(
tokenValue: $token->tokenValue,
password: self::PASSWORD,
);
$result = ($this->handler)($command);
self::assertInstanceOf(ActivateAccountResult::class, $result);
self::assertSame(self::USER_ID, $result->userId);
self::assertSame(self::EMAIL, $result->email);
self::assertSame(self::ROLE, $result->role);
self::assertSame(self::HASHED_PASSWORD, $result->hashedPassword);
}
#[Test]
public function activateAccountValidatesButDoesNotConsumeToken(): void
{
// Handler only validates the token - consumption is deferred to the processor
// after successful user activation, so failed activations don't burn the token
$token = $this->createAndSaveToken();
$tokenValue = $token->tokenValue;
$command = new ActivateAccountCommand(
tokenValue: $tokenValue,
password: self::PASSWORD,
);
($this->handler)($command);
// Token should still exist and NOT be marked as used
$updatedToken = $this->tokenRepository->findByTokenValue($tokenValue);
self::assertNotNull($updatedToken);
self::assertFalse($updatedToken->isUsed());
}
#[Test]
public function activateAccountThrowsWhenTokenNotFound(): void
{
$command = new ActivateAccountCommand(
tokenValue: 'non-existent-token',
password: self::PASSWORD,
);
$this->expectException(ActivationTokenNotFoundException::class);
($this->handler)($command);
}
#[Test]
public function activateAccountThrowsWhenTokenExpired(): void
{
$token = $this->createAndSaveToken(
createdAt: new DateTimeImmutable('2026-01-01 10:00:00'),
);
// Clock is set to 2026-01-16, token expires 2026-01-08
$command = new ActivateAccountCommand(
tokenValue: $token->tokenValue,
password: self::PASSWORD,
);
$this->expectException(ActivationTokenExpiredException::class);
($this->handler)($command);
}
#[Test]
public function activateAccountThrowsWhenTokenAlreadyUsed(): void
{
$token = $this->createAndSaveToken();
// Simulate a token that was already used (e.g., by the processor after successful activation)
$token->use($this->clock->now());
$this->tokenRepository->save($token);
$command = new ActivateAccountCommand(
tokenValue: $token->tokenValue,
password: self::PASSWORD,
);
// Should fail because token is already used
$this->expectException(ActivationTokenAlreadyUsedException::class);
($this->handler)($command);
}
private function createAndSaveToken(?DateTimeImmutable $createdAt = null): ActivationToken
{
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: $createdAt ?? new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->tokenRepository->save($token);
return $token;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
final class ActivationTokenIdTest extends TestCase
{
#[Test]
public function generateCreatesValidUuid(): void
{
$id = ActivationTokenId::generate();
self::assertInstanceOf(ActivationTokenId::class, $id);
self::assertTrue(Uuid::isValid((string) $id));
}
#[Test]
public function fromStringCreatesIdFromValidUuid(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$id = ActivationTokenId::fromString($uuid);
self::assertSame($uuid, (string) $id);
}
#[Test]
public function equalsReturnsTrueForSameValue(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$id1 = ActivationTokenId::fromString($uuid);
$id2 = ActivationTokenId::fromString($uuid);
self::assertTrue($id1->equals($id2));
}
#[Test]
public function equalsReturnsFalseForDifferentValue(): void
{
$id1 = ActivationTokenId::generate();
$id2 = ActivationTokenId::generate();
self::assertFalse($id1->equals($id2));
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\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\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ActivationTokenTest extends TestCase
{
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private const string ROLE = 'ROLE_PARENT';
private const string SCHOOL_NAME = 'École Alpha';
#[Test]
public function generateCreatesTokenWithCorrectProperties(): void
{
$userId = self::USER_ID;
$email = self::EMAIL;
$tenantId = TenantId::fromString(self::TENANT_ID);
$role = self::ROLE;
$schoolName = self::SCHOOL_NAME;
$now = new DateTimeImmutable('2026-01-15 10:00:00');
$token = ActivationToken::generate(
userId: $userId,
email: $email,
tenantId: $tenantId,
role: $role,
schoolName: $schoolName,
createdAt: $now,
);
self::assertInstanceOf(ActivationTokenId::class, $token->id);
self::assertSame($userId, $token->userId);
self::assertSame($email, $token->email);
self::assertTrue($tenantId->equals($token->tenantId));
self::assertSame($role, $token->role);
self::assertSame($schoolName, $token->schoolName);
self::assertEquals($now, $token->createdAt);
self::assertFalse($token->isUsed());
}
#[Test]
public function generateRecordsActivationTokenGeneratedEvent(): void
{
$token = $this->createToken();
$events = $token->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ActivationTokenGenerated::class, $events[0]);
}
#[Test]
public function tokenValueIsUuidV4Format(): void
{
$token = $this->createToken();
self::assertMatchesRegularExpression(
'/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
$token->tokenValue,
);
}
#[Test]
public function expiresAtIs7DaysAfterCreation(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$expectedExpiration = new DateTimeImmutable('2026-01-22 10:00:00');
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: $createdAt,
);
self::assertEquals($expectedExpiration, $token->expiresAt);
}
#[Test]
public function isExpiredReturnsFalseWhenNotExpired(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$checkAt = new DateTimeImmutable('2026-01-20 10:00:00');
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: $createdAt,
);
self::assertFalse($token->isExpired($checkAt));
}
#[Test]
public function isExpiredReturnsTrueWhenExpired(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$checkAt = new DateTimeImmutable('2026-01-25 10:00:00');
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: $createdAt,
);
self::assertTrue($token->isExpired($checkAt));
}
#[Test]
public function isExpiredReturnsTrueAtExactExpirationMoment(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$checkAt = new DateTimeImmutable('2026-01-22 10:00:00');
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: $createdAt,
);
self::assertTrue($token->isExpired($checkAt));
}
#[Test]
public function useMarksTokenAsUsed(): void
{
$token = $this->createToken();
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
$token->use($usedAt);
self::assertTrue($token->isUsed());
self::assertEquals($usedAt, $token->usedAt);
}
#[Test]
public function useRecordsActivationTokenUsedEvent(): void
{
$token = $this->createToken();
$token->pullDomainEvents();
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
$token->use($usedAt);
$events = $token->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ActivationTokenUsed::class, $events[0]);
}
#[Test]
public function useThrowsExceptionWhenTokenAlreadyUsed(): void
{
$token = $this->createToken();
$firstUse = new DateTimeImmutable('2026-01-16 10:00:00');
$token->use($firstUse);
$this->expectException(ActivationTokenAlreadyUsedException::class);
$secondUse = new DateTimeImmutable('2026-01-17 10:00:00');
$token->use($secondUse);
}
#[Test]
public function useThrowsExceptionWhenTokenExpired(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$usedAt = new DateTimeImmutable('2026-01-25 10:00:00');
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: $createdAt,
);
$this->expectException(ActivationTokenExpiredException::class);
$token->use($usedAt);
}
private function createToken(): ActivationToken
{
return ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
}

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\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\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\Policy\ConsentementParentalPolicy;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UserTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string SCHOOL_NAME = 'École Alpha';
private Clock $clock;
private ConsentementParentalPolicy $consentementPolicy;
protected function setUp(): void
{
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-31 10:00:00');
}
};
$this->consentementPolicy = new ConsentementParentalPolicy($this->clock);
}
#[Test]
public function creerCreatesUserWithPendingStatus(): void
{
$user = $this->createUser();
self::assertSame(StatutCompte::EN_ATTENTE, $user->statut);
self::assertNull($user->hashedPassword);
self::assertNull($user->activatedAt);
}
#[Test]
public function creerRecordsCompteCreatedEvent(): void
{
$user = $this->createUser();
$events = $user->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CompteCreated::class, $events[0]);
}
#[Test]
public function activerSetsPasswordAndChangesStatusToActive(): void
{
$user = $this->createUser();
$hashedPassword = '$argon2id$hashed';
$activatedAt = new DateTimeImmutable('2026-01-31 10:00:00');
$user->activer($hashedPassword, $activatedAt, $this->consentementPolicy);
self::assertSame(StatutCompte::ACTIF, $user->statut);
self::assertSame($hashedPassword, $user->hashedPassword);
self::assertEquals($activatedAt, $user->activatedAt);
}
#[Test]
public function activerRecordsCompteActiveEvent(): void
{
$user = $this->createUser();
$user->pullDomainEvents();
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
$events = $user->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CompteActive::class, $events[0]);
}
#[Test]
public function activerThrowsWhenStatusIsNotPending(): void
{
$user = $this->createUser();
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
$this->expectException(CompteNonActivableException::class);
$user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy);
}
#[Test]
public function activerThrowsForMinorWithoutConsent(): void
{
// Créer un utilisateur mineur (14 ans)
$user = User::creer(
email: new Email('eleve@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->expectException(CompteNonActivableException::class);
$this->expectExceptionMessage('consentement parental manquant');
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
}
#[Test]
public function activerSucceedsForMinorWithConsent(): void
{
// Créer un utilisateur mineur (14 ans)
$user = User::creer(
email: new Email('eleve@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
dateNaissance: new DateTimeImmutable('2012-06-15'),
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
// Enregistrer le consentement parental
$consentement = ConsentementParental::accorder(
parentId: 'parent-uuid',
eleveId: (string) $user->id,
at: new DateTimeImmutable('2026-01-20 10:00:00'),
ipAddress: '192.168.1.1',
);
$user->enregistrerConsentementParental($consentement);
// L'activation devrait maintenant réussir
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
self::assertSame(StatutCompte::ACTIF, $user->statut);
}
#[Test]
public function activerSucceedsForAdultWithoutConsent(): void
{
// Créer un utilisateur adulte (16 ans)
$user = User::creer(
email: new Email('eleve@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
dateNaissance: new DateTimeImmutable('2010-01-01'), // 16 ans
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
// Pas de consentement nécessaire
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
self::assertSame(StatutCompte::ACTIF, $user->statut);
}
#[Test]
public function peutSeConnecterReturnsTrueOnlyWhenActive(): void
{
$user = $this->createUser();
self::assertFalse($user->peutSeConnecter());
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
self::assertTrue($user->peutSeConnecter());
}
#[Test]
public function necessiteConsentementParentalReturnsTrueForMinor(): void
{
$user = User::creer(
email: new Email('eleve@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
self::assertTrue($user->necessiteConsentementParental($this->consentementPolicy));
}
#[Test]
public function necessiteConsentementParentalReturnsFalseForAdult(): void
{
$user = User::creer(
email: new Email('parent@example.com'),
role: Role::PARENT,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
dateNaissance: null, // Parents n'ont pas de date de naissance enregistrée
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
self::assertFalse($user->necessiteConsentementParental($this->consentementPolicy));
}
private function createUser(): User
{
return User::creer(
email: new Email('user@example.com'),
role: Role::PARENT,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Policy;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ConsentementParentalPolicyTest extends TestCase
{
private Clock $clock;
private ConsentementParentalPolicy $policy;
protected function setUp(): void
{
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-31 10:00:00');
}
};
$this->policy = new ConsentementParentalPolicy($this->clock);
}
#[Test]
public function consentementRequisPourUtilisateurDe14Ans(): void
{
$dateNaissance = new DateTimeImmutable('2012-01-31');
self::assertTrue($this->policy->estRequis($dateNaissance));
}
#[Test]
public function consentementRequisPourUtilisateurDe10Ans(): void
{
$dateNaissance = new DateTimeImmutable('2016-01-31');
self::assertTrue($this->policy->estRequis($dateNaissance));
}
#[Test]
public function consentementNonRequisPourUtilisateurDe15Ans(): void
{
$dateNaissance = new DateTimeImmutable('2011-01-30');
self::assertFalse($this->policy->estRequis($dateNaissance));
}
#[Test]
public function consentementNonRequisPourUtilisateurDe16Ans(): void
{
$dateNaissance = new DateTimeImmutable('2010-01-31');
self::assertFalse($this->policy->estRequis($dateNaissance));
}
#[Test]
public function consentementNonRequisSiDateNaissanceNulle(): void
{
self::assertFalse($this->policy->estRequis(null));
}
#[Test]
#[DataProvider('agesBordureProvider')]
public function consentementRequisAuxAgesBordure(
string $dateNaissance,
bool $consentementRequis,
string $description,
): void {
$result = $this->policy->estRequis(new DateTimeImmutable($dateNaissance));
self::assertSame($consentementRequis, $result, $description);
}
/**
* @return iterable<string, array{string, bool, string}>
*/
public static function agesBordureProvider(): iterable
{
// Current date is 2026-01-31
yield '14 ans et 364 jours' => [
'2011-02-01',
true,
'Un jour avant 15 ans → consentement requis',
];
yield '15 ans exactement' => [
'2011-01-31',
false,
'Le jour des 15 ans → consentement non requis',
];
yield '15 ans et 1 jour' => [
'2011-01-30',
false,
'Un jour après 15 ans → consentement non requis',
];
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Tests for ActivateAccountProcessor focusing on token consumption behavior.
*
* Key invariant: Failed activations must NOT consume the token, allowing retries.
*/
final class ActivateAccountProcessorTest extends TestCase
{
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private const string ROLE = 'ROLE_PARENT';
private const string SCHOOL_NAME = 'École Test';
private const string PASSWORD = 'SecurePass123';
private const string HASHED_PASSWORD = '$argon2id$hashed';
private InMemoryActivationTokenRepository $tokenRepository;
private Clock $clock;
protected function setUp(): void
{
$this->tokenRepository = new InMemoryActivationTokenRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-16 10:00:00');
}
};
}
#[Test]
public function tokenRemainsValidWhenUserNotFound(): void
{
// Arrange: Create a valid token
$token = $this->createAndSaveToken();
$tokenValue = $token->tokenValue;
// Create processor with a UserRepository that throws UserNotFoundException
$processor = $this->createProcessorWithMissingUser();
$input = new ActivateAccountInput();
$input->tokenValue = $tokenValue;
$input->password = self::PASSWORD;
// Act: Try to activate (should fail because user not found)
try {
$processor->process($input, new Post());
self::fail('Expected NotFoundHttpException to be thrown');
} catch (NotFoundHttpException $e) {
self::assertSame('Utilisateur introuvable.', $e->getMessage());
}
// Assert: Token should NOT be consumed - retry should be possible
$tokenAfterFailure = $this->tokenRepository->findByTokenValue($tokenValue);
self::assertNotNull($tokenAfterFailure, 'Token should still exist after failed activation');
self::assertFalse($tokenAfterFailure->isUsed(), 'Token should NOT be marked as used after failed activation');
}
#[Test]
public function tokenCanBeReusedAfterFailedActivation(): void
{
// Arrange: Create a valid token
$token = $this->createAndSaveToken();
$tokenValue = $token->tokenValue;
$processorWithMissingUser = $this->createProcessorWithMissingUser();
$input = new ActivateAccountInput();
$input->tokenValue = $tokenValue;
$input->password = self::PASSWORD;
// Act: First activation fails (user not found)
try {
$processorWithMissingUser->process($input, new Post());
} catch (NotFoundHttpException) {
// Expected
}
// Assert: Can call handler again with same token (retry scenario)
$handler = $this->createHandler();
$result = ($handler)(new \App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand(
tokenValue: $tokenValue,
password: self::PASSWORD,
));
// Should succeed - token was not burned
self::assertSame(self::USER_ID, $result->userId);
}
private function createAndSaveToken(): ActivationToken
{
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->tokenRepository->save($token);
return $token;
}
private function createHandler(): ActivateAccountHandler
{
$passwordHasher = new class implements PasswordHasher {
public function hash(string $plainPassword): string
{
return '$argon2id$hashed';
}
public function verify(string $hashedPassword, string $plainPassword): bool
{
return true;
}
};
return new ActivateAccountHandler(
$this->tokenRepository,
$passwordHasher,
$this->clock,
);
}
private function createProcessorWithMissingUser(): ActivateAccountProcessor
{
$handler = $this->createHandler();
// UserRepository that always throws UserNotFoundException
$userRepository = new class implements UserRepository {
public function save(\App\Administration\Domain\Model\User\User $user): void
{
}
public function findById(UserId $id): ?\App\Administration\Domain\Model\User\User
{
return null;
}
public function findByEmail(\App\Administration\Domain\Model\User\Email $email): ?\App\Administration\Domain\Model\User\User
{
return null;
}
public function get(UserId $id): \App\Administration\Domain\Model\User\User
{
throw UserNotFoundException::withId($id);
}
};
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
$eventBus = $this->createMock(MessageBusInterface::class);
return new ActivateAccountProcessor(
$handler,
$userRepository,
$this->tokenRepository,
$consentementPolicy,
$this->clock,
$eventBus,
);
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Cache;
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\Cache\CacheUserRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
/**
* Tests for CacheUserRepository.
*
* Key invariant: Users must not expire from cache (unlike activation tokens which have 7-day TTL).
* This was a bug where users were stored in the activation_tokens.cache pool with TTL,
* causing activated accounts to become inaccessible after 7 days.
*/
final class CacheUserRepositoryTest extends TestCase
{
#[Test]
public function userIsSavedWithoutExpiration(): void
{
// Arrange: Create a mock cache that tracks expiration settings
$expirationSet = null;
$cacheItem = $this->createMock(CacheItemInterface::class);
$cacheItem->method('set')->willReturnSelf();
$cacheItem->method('expiresAfter')
->willReturnCallback(static function ($ttl) use (&$expirationSet, $cacheItem) {
$expirationSet = $ttl;
return $cacheItem;
});
$cachePool = $this->createMock(CacheItemPoolInterface::class);
$cachePool->method('getItem')->willReturn($cacheItem);
$cachePool->method('save')->willReturn(true);
$repository = new CacheUserRepository($cachePool);
$user = User::creer(
email: new Email('test@example.com'),
role: Role::PARENT,
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
schoolName: 'École Test',
dateNaissance: null,
createdAt: new DateTimeImmutable(),
);
// Act
$repository->save($user);
// Assert: No expiration should be set (expiresAfter should not be called with a TTL)
// The users.cache pool is configured with default_lifetime: 0 (no expiration)
// But CacheUserRepository should NOT explicitly set any TTL
self::assertNull(
$expirationSet,
'User cache entries should not have explicit expiration set by the repository'
);
}
#[Test]
public function userCanBeRetrievedById(): void
{
// Arrange
$userId = '550e8400-e29b-41d4-a716-446655440001';
$email = 'test@example.com';
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
$userData = [
'id' => $userId,
'email' => $email,
'role' => 'ROLE_PARENT',
'tenant_id' => $tenantId,
'school_name' => 'École Test',
'statut' => 'pending',
'hashed_password' => null,
'date_naissance' => null,
'created_at' => '2026-01-15T10:00:00+00:00',
'activated_at' => null,
'consentement_parental' => null,
];
$cacheItem = $this->createMock(CacheItemInterface::class);
$cacheItem->method('isHit')->willReturn(true);
$cacheItem->method('get')->willReturn($userData);
$cachePool = $this->createMock(CacheItemPoolInterface::class);
$cachePool->method('getItem')->willReturn($cacheItem);
$repository = new CacheUserRepository($cachePool);
// Act
$user = $repository->findById(\App\Administration\Domain\Model\User\UserId::fromString($userId));
// Assert
self::assertNotNull($user);
self::assertSame($userId, (string) $user->id);
self::assertSame($email, (string) $user->email);
self::assertSame(Role::PARENT, $user->role);
}
#[Test]
public function userCanBeRetrievedByEmail(): void
{
// Arrange
$userId = '550e8400-e29b-41d4-a716-446655440001';
$email = 'test@example.com';
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
$userData = [
'id' => $userId,
'email' => $email,
'role' => 'ROLE_PARENT',
'tenant_id' => $tenantId,
'school_name' => 'École Test',
'statut' => 'pending',
'hashed_password' => null,
'date_naissance' => null,
'created_at' => '2026-01-15T10:00:00+00:00',
'activated_at' => null,
'consentement_parental' => null,
];
$emailIndexItem = $this->createMock(CacheItemInterface::class);
$emailIndexItem->method('isHit')->willReturn(true);
$emailIndexItem->method('get')->willReturn($userId);
$userItem = $this->createMock(CacheItemInterface::class);
$userItem->method('isHit')->willReturn(true);
$userItem->method('get')->willReturn($userData);
$cachePool = $this->createMock(CacheItemPoolInterface::class);
$cachePool->method('getItem')
->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem) {
if (str_starts_with($key, 'user_email:')) {
return $emailIndexItem;
}
return $userItem;
});
$repository = new CacheUserRepository($cachePool);
// Act
$user = $repository->findByEmail(new Email($email));
// Assert
self::assertNotNull($user);
self::assertSame($userId, (string) $user->id);
self::assertSame($email, (string) $user->email);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class InMemoryActivationTokenRepositoryTest extends TestCase
{
private InMemoryActivationTokenRepository $repository;
protected function setUp(): void
{
$this->repository = new InMemoryActivationTokenRepository();
}
#[Test]
public function saveAndFindByTokenValue(): void
{
$token = $this->createToken();
$this->repository->save($token);
$found = $this->repository->findByTokenValue($token->tokenValue);
self::assertSame($token, $found);
}
#[Test]
public function saveAndGetById(): void
{
$token = $this->createToken();
$this->repository->save($token);
$found = $this->repository->get($token->id);
self::assertSame($token, $found);
}
#[Test]
public function findByTokenValueReturnsNullWhenNotFound(): void
{
$result = $this->repository->findByTokenValue('non-existent-token');
self::assertNull($result);
}
#[Test]
public function getThrowsExceptionWhenNotFound(): void
{
$this->expectException(ActivationTokenNotFoundException::class);
$this->repository->get(ActivationTokenId::generate());
}
#[Test]
public function deleteRemovesToken(): void
{
$token = $this->createToken();
$this->repository->save($token);
$this->repository->delete($token->id);
self::assertNull($this->repository->findByTokenValue($token->tokenValue));
}
#[Test]
public function deleteRemovesTokenFromIdIndex(): void
{
$token = $this->createToken();
$this->repository->save($token);
$this->repository->delete($token->id);
$this->expectException(ActivationTokenNotFoundException::class);
$this->repository->get($token->id);
}
#[Test]
public function deleteNonExistentTokenDoesNotThrow(): void
{
$this->repository->delete(ActivationTokenId::generate());
$this->addToAssertionCount(1); // No exception thrown
}
#[Test]
public function saveUpdatesExistingToken(): void
{
$token = $this->createToken();
$this->repository->save($token);
// Modify the token (mark as used)
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
$token->use($usedAt);
$this->repository->save($token);
$found = $this->repository->findByTokenValue($token->tokenValue);
self::assertTrue($found?->isUsed());
}
private function createToken(): ActivationToken
{
return ActivationToken::generate(
userId: '550e8400-e29b-41d4-a716-446655440001',
email: 'user@example.com',
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
role: 'ROLE_PARENT',
schoolName: 'École Alpha',
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
}

2
frontend/.gitignore vendored
View File

@@ -35,3 +35,5 @@ dev-dist/
# =============================================================================
*.local
*.tsbuildinfo
# Generated test token for E2E tests
e2e/.test-token

View File

@@ -0,0 +1,187 @@
import { test, expect } from '@playwright/test';
import { getTestToken } from './test-utils';
test.describe('Account Activation Flow', () => {
test.describe('Token Validation', () => {
test('displays error for invalid token', async ({ page }) => {
await page.goto('/activate/invalid-token-uuid-format');
// Wait for the error state
await expect(page.getByRole('heading', { name: /lien invalide/i })).toBeVisible();
await expect(page.getByText(/contacter votre établissement/i)).toBeVisible();
});
test('displays error for non-existent token', async ({ page }) => {
// Use a valid UUID format but non-existent token
await page.goto('/activate/00000000-0000-0000-0000-000000000000');
// Shows error because token doesn't exist
const heading = page.getByRole('heading', { name: /lien invalide/i });
await expect(heading).toBeVisible();
});
});
test.describe('Password Form', () => {
test('validates password requirements in real-time', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
// Wait for form to be visible (token must be valid)
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
// Test minimum length requirement - should NOT be valid yet
await passwordInput.fill('Abc1');
const minLengthItem = page.locator('.password-requirements li').filter({ hasText: /8 caractères/ });
await expect(minLengthItem).not.toHaveClass(/valid/);
// Test uppercase requirement - missing
await passwordInput.fill('abcd1234');
const uppercaseItem = page.locator('.password-requirements li').filter({ hasText: /majuscule/ });
await expect(uppercaseItem).not.toHaveClass(/valid/);
// Test digit requirement - missing
await passwordInput.fill('Abcdefgh');
const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ });
await expect(digitItem).not.toHaveClass(/valid/);
// Valid password should show all checkmarks
await passwordInput.fill('Abcdefgh1');
const validItems = page.locator('.password-requirements li.valid');
await expect(validItems).toHaveCount(3);
});
test('requires password confirmation to match', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
const confirmInput = page.locator('#passwordConfirmation');
await passwordInput.fill('SecurePass123');
await confirmInput.fill('DifferentPass123');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible();
// Fix confirmation
await confirmInput.fill('SecurePass123');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
});
test('submit button is disabled until form is valid', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const submitButton = page.getByRole('button', { name: /activer mon compte/i });
// Initially disabled
await expect(submitButton).toBeDisabled();
// Fill valid password
await page.locator('#password').fill('SecurePass123');
await page.locator('#passwordConfirmation').fill('SecurePass123');
// Should now be enabled
await expect(submitButton).toBeEnabled();
});
});
test.describe('Establishment Info Display', () => {
test('shows establishment name and role when token is valid', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
// School info should be visible
await expect(page.locator('.school-info')).toBeVisible();
await expect(page.locator('.school-name')).toBeVisible();
await expect(page.locator('.account-type')).toBeVisible();
});
});
test.describe('Password Visibility Toggle', () => {
test('toggles password visibility', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
const toggleButton = page.locator('.toggle-password');
// Initially password type
await expect(passwordInput).toHaveAttribute('type', 'password');
// Click toggle
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'text');
// Click again to hide
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'password');
});
});
test.describe('Full Activation Flow', () => {
test('activates account and redirects to login', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
// Fill valid password
await page.locator('#password').fill('SecurePass123');
await page.locator('#passwordConfirmation').fill('SecurePass123');
// Submit
await page.getByRole('button', { name: /activer mon compte/i }).click();
// Should redirect to login with success message
await expect(page).toHaveURL(/\/login\?activated=true/);
await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible();
});
});
});
test.describe('Login Page After Activation', () => {
test('shows success message when redirected after activation', async ({ page }) => {
await page.goto('/login?activated=true');
await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible();
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
});
test('does not show success message without query param', async ({ page }) => {
await page.goto('/login');
await expect(page.getByText(/compte a été activé avec succès/i)).not.toBeVisible();
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
});
});
test.describe('Parental Consent Flow (Minor User)', () => {
// These tests would require seeded data for a minor user
test.skip('shows consent required message for minor without consent', async () => {
// Would navigate to activation page for a minor user token
// and verify the consent required message is displayed
});
test.skip('allows activation after parental consent is given', async () => {
// Would verify the full flow:
// 1. Minor receives activation link
// 2. Parent gives consent
// 3. Minor can then activate their account
});
});

View File

@@ -0,0 +1,52 @@
import { execSync } from 'child_process';
import { writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Global setup for E2E tests.
* Seeds a test activation token before tests run.
*/
async function globalSetup() {
console.warn('🌱 Seeding test activation token...');
try {
// Call the backend command to create a test token
// Project root is 2 levels up from frontend/e2e/
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
const result = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-activation-token --email=e2e-test@example.com 2>&1`,
{
encoding: 'utf-8'
}
);
// Extract the token from the output
// Output format: "Token f9174245-9766-4ef1-b6e9-a6795aa2da04"
const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i);
if (!tokenMatch) {
console.error('❌ Could not extract token from output:', result);
throw new Error('Failed to extract token from command output');
}
const token = tokenMatch[1];
console.warn(`✅ Test token created: ${token}`);
// Write the token to a file for tests to use
const tokenFile = join(__dirname, '.test-token');
writeFileSync(tokenFile, token);
console.warn('✅ Token saved to .test-token file');
} catch (error) {
console.error('❌ Failed to seed test token:', error);
// Don't throw - tests can still run with skipped token-dependent tests
console.warn('⚠️ Tests requiring valid tokens will be skipped');
}
}
export default globalSetup;

View File

@@ -0,0 +1,22 @@
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Get the seeded test token.
* The token is created by global-setup.ts before tests run via Docker.
*/
export function getTestToken(): string {
const tokenFile = join(__dirname, '.test-token');
if (existsSync(tokenFile)) {
return readFileSync(tokenFile, 'utf-8').trim();
}
throw new Error(
'No .test-token file found. Make sure Docker is running and global-setup.ts executed successfully.'
);
}

View File

@@ -73,7 +73,10 @@ export default tseslint.config(
process: 'readonly',
Promise: 'readonly',
Set: 'readonly',
Map: 'readonly'
Map: 'readonly',
Event: 'readonly',
SubmitEvent: 'readonly',
fetch: 'readonly'
}
},
plugins: {

View File

@@ -1,15 +1,23 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const useExternalServer = !!process.env.PLAYWRIGHT_BASE_URL;
const config: PlaywrightTestConfig = {
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 4173,
reuseExistingServer: !process.env.CI
},
// Always run globalSetup to seed test tokens
// If backend is not running, tests requiring tokens will be skipped gracefully
globalSetup: './e2e/global-setup.ts',
webServer: useExternalServer
? undefined
: {
command: 'pnpm run build && pnpm run preview',
port: 4173,
reuseExistingServer: !process.env.CI
},
testDir: 'e2e',
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
use: {
baseURL: 'http://localhost:4173',
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'

View File

@@ -0,0 +1,41 @@
/**
* Types for account activation flow.
*/
export interface ActivationTokenInfo {
tokenValue: string;
email: string;
role: string;
schoolName: string;
isExpired: boolean;
expiresAt: string;
}
export interface ActivateAccountInput {
tokenValue: string;
password: string;
}
export interface ActivateAccountOutput {
userId: string;
email: string;
role: string;
message: string;
}
export type ActivationError =
| 'TOKEN_NOT_FOUND'
| 'TOKEN_EXPIRED'
| 'TOKEN_ALREADY_USED'
| 'VALIDATION_ERROR'
| 'NETWORK_ERROR';
export interface ActivationErrorResponse {
'@type': string;
title: string;
detail: string;
violations?: Array<{
propertyPath: string;
message: string;
}>;
}

View File

@@ -0,0 +1,649 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { createQuery, createMutation } from '@tanstack/svelte-query';
import { getApiBaseUrl } from '$lib/api/config';
import type {
ActivationTokenInfo,
ActivateAccountInput,
ActivationErrorResponse
} from '$lib/types/activation';
const token = $derived($page.params.token ?? '');
const apiBaseUrl = getApiBaseUrl();
// État du formulaire
let password = $state('');
let passwordConfirmation = $state('');
let showPassword = $state(false);
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
// Critères de validation du mot de passe
const hasMinLength = $derived(password.length >= 8);
const hasUppercase = $derived(/[A-Z]/.test(password));
const hasDigit = $derived(/[0-9]/.test(password));
const passwordsMatch = $derived(password === passwordConfirmation && password.length > 0);
const isPasswordValid = $derived(hasMinLength && hasUppercase && hasDigit && passwordsMatch);
// Query pour récupérer les infos du token
// svelte-ignore state_referenced_locally
// The token comes from URL params and won't change during component lifecycle
const tokenInfoQuery = createQuery<ActivationTokenInfo>({
queryKey: ['activationToken', token] as const,
queryFn: async () => {
const currentToken = $page.params.token ?? '';
const response = await globalThis.fetch(`${apiBaseUrl}/activation-tokens/${currentToken}`, {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
const errorData = (await response.json()) as ActivationErrorResponse;
throw new Error(errorData.detail || 'Token invalide');
}
return response.json() as Promise<ActivationTokenInfo>;
},
retry: false
});
// Mutation pour activer le compte
const activateMutation = createMutation({
mutationFn: async (data: ActivateAccountInput) => {
const response = await globalThis.fetch(`${apiBaseUrl}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
const errorData = (await response.json()) as ActivationErrorResponse;
throw errorData;
}
return response.json();
},
onSuccess: () => {
goto('/login?activated=true');
},
onError: (error: ActivationErrorResponse) => {
formError = '';
fieldErrors = {};
if (error.violations) {
for (const violation of error.violations) {
fieldErrors[violation.propertyPath] = violation.message;
}
} else {
formError = error.detail || "Une erreur est survenue lors de l'activation.";
}
}
});
function handleSubmit(event: Event) {
event.preventDefault();
if (!isPasswordValid) {
formError = 'Veuillez corriger les erreurs avant de continuer.';
return;
}
formError = '';
fieldErrors = {};
$activateMutation.mutate({
tokenValue: token,
password: password
});
}
</script>
<svelte:head>
<title>Activation de compte | Classeo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<div class="activation-page">
<div class="activation-container">
<!-- Logo -->
<div class="logo">
<span class="logo-icon">📚</span>
<span class="logo-text">Classeo</span>
</div>
{#if $tokenInfoQuery.isPending}
<!-- Loading state -->
<div class="card">
<div class="loading-state">
<div class="spinner"></div>
<p>Vérification du lien d'activation...</p>
</div>
</div>
{:else if $tokenInfoQuery.isError}
<!-- Error state -->
<div class="card">
<div class="error-state">
<div class="error-icon"></div>
<h2>Lien invalide</h2>
<p>Ce lien d'activation est invalide ou a expiré.</p>
<p class="hint">Veuillez contacter votre établissement pour obtenir un nouveau lien.</p>
</div>
</div>
{:else if $tokenInfoQuery.data}
{@const tokenInfo = $tokenInfoQuery.data}
{#if tokenInfo.isExpired}
<!-- Token expired -->
<div class="card">
<div class="error-state warning">
<div class="error-icon"></div>
<h2>Lien expiré</h2>
<p>Votre lien d'activation a expiré (validité : 7 jours).</p>
<p class="hint">
Veuillez contacter <strong>{tokenInfo.schoolName}</strong> pour obtenir un nouveau lien.
</p>
</div>
</div>
{:else}
<!-- Activation form -->
<div class="card">
<h1>Activation de votre compte</h1>
<!-- Info établissement -->
<div class="school-info">
<div class="school-icon">🏫</div>
<div class="school-details">
<span class="school-name">{tokenInfo.schoolName}</span>
<span class="account-type">{tokenInfo.role}</span>
</div>
</div>
<div class="email-badge">
{tokenInfo.email}
</div>
<form onsubmit={handleSubmit}>
{#if formError}
<div class="form-error">
<span class="error-badge">!</span>
{formError}
</div>
{/if}
<!-- Mot de passe -->
<div class="form-group">
<label for="password">Créer votre mot de passe</label>
<div class="input-wrapper">
<input
id="password"
type={showPassword ? 'text' : 'password'}
bind:value={password}
required
placeholder="Entrez votre mot de passe"
class:has-error={fieldErrors['password']}
/>
<button type="button" class="toggle-password" onclick={() => (showPassword = !showPassword)}>
{showPassword ? '🙈' : '👁'}
</button>
</div>
{#if fieldErrors['password']}
<span class="field-error">{fieldErrors['password']}</span>
{/if}
</div>
<!-- Critères de validation -->
<div class="password-requirements">
<span class="requirements-title">Votre mot de passe doit contenir :</span>
<ul>
<li class:valid={hasMinLength}>
<span class="check">{hasMinLength ? '✓' : '○'}</span>
Au moins 8 caractères
</li>
<li class:valid={hasUppercase}>
<span class="check">{hasUppercase ? '✓' : '○'}</span>
Au moins 1 majuscule
</li>
<li class:valid={hasDigit}>
<span class="check">{hasDigit ? '✓' : '○'}</span>
Au moins 1 chiffre
</li>
</ul>
</div>
<!-- Confirmation mot de passe -->
<div class="form-group">
<label for="passwordConfirmation">Confirmer le mot de passe</label>
<div class="input-wrapper">
<input
id="passwordConfirmation"
type={showPassword ? 'text' : 'password'}
bind:value={passwordConfirmation}
required
placeholder="Confirmez votre mot de passe"
class:has-error={passwordConfirmation.length > 0 && !passwordsMatch}
/>
</div>
{#if passwordConfirmation.length > 0 && !passwordsMatch}
<span class="field-error">Les mots de passe ne correspondent pas.</span>
{/if}
</div>
<!-- Bouton submit -->
<button type="submit" class="submit-button" disabled={!isPasswordValid || $activateMutation.isPending}>
{#if $activateMutation.isPending}
<span class="button-spinner"></span>
Activation en cours...
{:else}
Activer mon compte
{/if}
</button>
</form>
</div>
{/if}
{/if}
<!-- Footer -->
<p class="footer">Un problème ? Contactez votre établissement.</p>
</div>
</div>
<style>
/* Design Tokens - Calm Productivity */
:root {
--color-calm: hsl(142, 76%, 36%);
--color-attention: hsl(38, 92%, 50%);
--color-alert: hsl(0, 72%, 51%);
--surface-primary: hsl(210, 20%, 98%);
--surface-elevated: hsl(0, 0%, 100%);
--text-primary: hsl(222, 47%, 11%);
--text-secondary: hsl(215, 16%, 47%);
--text-muted: hsl(215, 13%, 65%);
--accent-primary: hsl(199, 89%, 48%);
--border-subtle: hsl(214, 32%, 91%);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
}
.activation-page {
min-height: 100vh;
background: var(--surface-primary);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
.activation-container {
width: 100%;
max-width: 420px;
}
/* Logo */
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 32px;
}
.logo-icon {
font-size: 32px;
}
.logo-text {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
/* Card */
.card {
background: var(--surface-elevated);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-elevated);
padding: 32px;
}
.card h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 24px;
text-align: center;
}
/* School Info */
.school-info {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: linear-gradient(135deg, hsl(199, 89%, 96%) 0%, hsl(199, 89%, 98%) 100%);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.school-icon {
font-size: 32px;
}
.school-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.school-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.account-type {
font-size: 14px;
color: var(--accent-primary);
font-weight: 500;
}
.email-badge {
display: inline-block;
width: 100%;
text-align: center;
padding: 10px 16px;
background: var(--surface-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
/* Form */
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.input-wrapper {
position: relative;
}
.input-wrapper input {
width: 100%;
padding: 12px 16px;
padding-right: 48px;
font-size: 15px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-wrapper input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px hsla(199, 89%, 48%, 0.15);
}
.input-wrapper input.has-error {
border-color: var(--color-alert);
}
.input-wrapper input::placeholder {
color: var(--text-muted);
}
.toggle-password {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 4px;
opacity: 0.6;
transition: opacity 0.2s;
}
.toggle-password:hover {
opacity: 1;
}
.field-error {
font-size: 13px;
color: var(--color-alert);
}
/* Password Requirements */
.password-requirements {
padding: 16px;
background: var(--surface-primary);
border-radius: var(--radius-sm);
}
.requirements-title {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
display: block;
margin-bottom: 12px;
}
.password-requirements ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.password-requirements li {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--text-muted);
transition: color 0.2s;
}
.password-requirements li.valid {
color: var(--color-calm);
}
.password-requirements li .check {
font-size: 14px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--border-subtle);
color: var(--text-muted);
font-weight: 600;
transition: background 0.2s, color 0.2s;
}
.password-requirements li.valid .check {
background: var(--color-calm);
color: white;
}
/* Submit Button */
.submit-button {
width: 100%;
padding: 14px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background: var(--accent-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.submit-button:hover:not(:disabled) {
background: hsl(199, 89%, 42%);
box-shadow: var(--shadow-card);
}
.submit-button:active:not(:disabled) {
transform: scale(0.98);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px;
}
.loading-state p {
color: var(--text-secondary);
font-size: 15px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-subtle);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error State */
.error-state {
text-align: center;
padding: 16px;
}
.error-state .error-icon {
width: 56px;
height: 56px;
margin: 0 auto 20px;
background: hsl(0, 72%, 95%);
color: var(--color-alert);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.error-state.warning .error-icon {
background: hsl(38, 92%, 95%);
color: var(--color-attention);
}
.error-state h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.error-state p {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.error-state .hint {
font-size: 14px;
color: var(--text-muted);
}
/* Form Error */
.form-error {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: hsl(0, 72%, 97%);
border: 1px solid hsl(0, 72%, 90%);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--color-alert);
}
.error-badge {
width: 20px;
height: 20px;
background: var(--color-alert);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
/* Footer */
.footer {
text-align: center;
font-size: 14px;
color: var(--text-muted);
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,257 @@
<script lang="ts">
import { page } from '$app/stores';
const justActivated = $derived($page.url.searchParams.get('activated') === 'true');
</script>
<svelte:head>
<title>Connexion | Classeo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<div class="login-page">
<div class="login-container">
<!-- Logo -->
<div class="logo">
<span class="logo-icon">📚</span>
<span class="logo-text">Classeo</span>
</div>
<div class="card">
{#if justActivated}
<div class="success-banner">
<span class="success-icon"></span>
<span>Votre compte a été activé avec succès !</span>
</div>
{/if}
<h1>Connexion</h1>
<form>
<div class="form-group">
<label for="email">Adresse email</label>
<div class="input-wrapper">
<input
id="email"
type="email"
required
placeholder="votre@email.com"
/>
</div>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<div class="input-wrapper">
<input
id="password"
type="password"
required
placeholder="Votre mot de passe"
/>
</div>
</div>
<button type="submit" class="submit-button">
Se connecter
</button>
</form>
<p class="help-text">
La connexion sera disponible prochainement.
</p>
</div>
<p class="footer">Un problème ? Contactez votre établissement.</p>
</div>
</div>
<style>
/* Design Tokens - Calm Productivity */
:root {
--color-calm: hsl(142, 76%, 36%);
--color-attention: hsl(38, 92%, 50%);
--color-alert: hsl(0, 72%, 51%);
--surface-primary: hsl(210, 20%, 98%);
--surface-elevated: hsl(0, 0%, 100%);
--text-primary: hsl(222, 47%, 11%);
--text-secondary: hsl(215, 16%, 47%);
--text-muted: hsl(215, 13%, 65%);
--accent-primary: hsl(199, 89%, 48%);
--border-subtle: hsl(214, 32%, 91%);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
}
.login-page {
min-height: 100vh;
background: var(--surface-primary);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
.login-container {
width: 100%;
max-width: 420px;
}
/* Logo */
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 32px;
}
.logo-icon {
font-size: 32px;
}
.logo-text {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
/* Card */
.card {
background: var(--surface-elevated);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-elevated);
padding: 32px;
}
.card h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 24px;
text-align: center;
}
/* Success Banner */
.success-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: linear-gradient(135deg, hsl(142, 76%, 95%) 0%, hsl(142, 76%, 97%) 100%);
border: 1px solid hsl(142, 76%, 85%);
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 14px;
font-weight: 500;
color: var(--color-calm);
}
.success-icon {
width: 24px;
height: 24px;
background: var(--color-calm);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
/* Form */
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.input-wrapper {
position: relative;
}
.input-wrapper input {
width: 100%;
padding: 12px 16px;
font-size: 15px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-wrapper input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px hsla(199, 89%, 48%, 0.15);
}
.input-wrapper input::placeholder {
color: var(--text-muted);
}
/* Submit Button */
.submit-button {
width: 100%;
padding: 14px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background: var(--accent-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.submit-button:hover {
background: hsl(199, 89%, 42%);
box-shadow: var(--shadow-card);
}
.submit-button:active {
transform: scale(0.98);
}
/* Help Text */
.help-text {
text-align: center;
font-size: 13px;
color: var(--text-muted);
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-subtle);
}
/* Footer */
.footer {
text-align: center;
font-size: 14px;
color: var(--text-muted);
margin-top: 24px;
}
</style>