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:
69
.github/workflows/ci.yml
vendored
69
.github/workflows/ci.yml
vendored
@@ -128,11 +128,74 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm run test
|
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
|
- name: Install Playwright browsers
|
||||||
|
working-directory: frontend
|
||||||
run: pnpm exec playwright install --with-deps
|
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
|
- name: Run E2E tests
|
||||||
|
working-directory: frontend
|
||||||
run: pnpm run test:e2e
|
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
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -142,6 +205,10 @@ jobs:
|
|||||||
path: frontend/playwright-report/
|
path: frontend/playwright-report/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Stop backend services
|
||||||
|
if: always()
|
||||||
|
run: docker compose down
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Naming Conventions Check
|
# Naming Conventions Check
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -161,7 +228,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build Check
|
name: Build Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test-backend, test-frontend]
|
needs: [test-backend, test-frontend, test-e2e]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
36
Makefile
36
Makefile
@@ -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
|
# Default target
|
||||||
help:
|
help:
|
||||||
@@ -14,6 +14,12 @@ help:
|
|||||||
@echo " make ps - Statut des services"
|
@echo " make ps - Statut des services"
|
||||||
@echo " make clean - Supprimer volumes et images"
|
@echo " make clean - Supprimer volumes et images"
|
||||||
@echo ""
|
@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 "Backend:"
|
||||||
@echo " make phpstan - Analyse statique PHPStan"
|
@echo " make phpstan - Analyse statique PHPStan"
|
||||||
@echo " make arch - Tests d'architecture (PHPat)"
|
@echo " make arch - Tests d'architecture (PHPat)"
|
||||||
@@ -61,6 +67,21 @@ ps:
|
|||||||
clean:
|
clean:
|
||||||
docker compose down -v --rmi local
|
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
|
# Backend
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -119,3 +140,16 @@ check-naming:
|
|||||||
|
|
||||||
check-tenants:
|
check-tenants:
|
||||||
./scripts/check-tenants.sh
|
./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
|
||||||
|
|||||||
@@ -63,3 +63,12 @@ DEFAULT_URI=http://localhost
|
|||||||
# Base domain for tenant resolution (e.g., classeo.fr, classeo.local)
|
# Base domain for tenant resolution (e.g., classeo.fr, classeo.local)
|
||||||
TENANT_BASE_DOMAIN=classeo.local
|
TENANT_BASE_DOMAIN=classeo.local
|
||||||
###< multi-tenant ###
|
###< 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 ###
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ RUN echo "xdebug.mode=develop,debug,coverage" >> "$PHP_INI_DIR/conf.d/xdebug.ini
|
|||||||
|
|
||||||
# Caddy config for FrankenPHP
|
# Caddy config for FrankenPHP
|
||||||
ENV SERVER_NAME=:8000
|
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
|
# Entrypoint: detect host UID/GID and run as matching user
|
||||||
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)
|
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
"doctrine/orm": "^3.3",
|
"doctrine/orm": "^3.3",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
"symfony/amqp-messenger": "^8.0",
|
"symfony/amqp-messenger": "^8.0",
|
||||||
"symfony/asset": "^8.0",
|
"symfony/asset": "^8.0",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"symfony/dotenv": "^8.0",
|
"symfony/dotenv": "^8.0",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "^8.0",
|
"symfony/framework-bundle": "^8.0",
|
||||||
|
"symfony/mailer": "8.0.*",
|
||||||
"symfony/messenger": "^8.0",
|
"symfony/messenger": "^8.0",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/property-access": "^8.0",
|
"symfony/property-access": "^8.0",
|
||||||
|
|||||||
387
backend/composer.lock
generated
387
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "5db4139b65c041189bc59e0582d6f82d",
|
"content-hash": "e5abd2128a53127e2298b296ed587025",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/core",
|
"name": "api-platform/core",
|
||||||
@@ -1390,6 +1390,73 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-10-26T09:35:14+00:00"
|
"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",
|
"name": "lcobucci/jwt",
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
@@ -1682,6 +1749,71 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"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",
|
"name": "psr/cache",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@@ -3883,6 +4015,86 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-28T10:46:31+00:00"
|
"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",
|
"name": "symfony/messenger",
|
||||||
"version": "v8.0.4",
|
"version": "v8.0.4",
|
||||||
@@ -3973,6 +4185,92 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-08T22:36:47+00:00"
|
"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",
|
"name": "symfony/monolog-bridge",
|
||||||
"version": "v8.0.4",
|
"version": "v8.0.4",
|
||||||
@@ -4284,6 +4582,93 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-06-27T09:58:17+00:00"
|
"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",
|
"name": "symfony/polyfill-intl-normalizer",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\TwigBundle\TwigBundle::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\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ framework:
|
|||||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||||
prefix_seed: classeo/backend
|
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:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
cache:
|
cache:
|
||||||
@@ -11,3 +22,11 @@ when@prod:
|
|||||||
adapter: cache.adapter.system
|
adapter: cache.adapter.system
|
||||||
doctrine.result_cache_pool:
|
doctrine.result_cache_pool:
|
||||||
adapter: cache.adapter.system
|
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
|
||||||
|
|||||||
3
backend/config/packages/mailer.yaml
Normal file
3
backend/config/packages/mailer.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
mailer:
|
||||||
|
dsn: '%env(MAILER_DSN)%'
|
||||||
10
backend/config/packages/nelmio_cors.yaml
Normal file
10
backend/config/packages/nelmio_cors.yaml
Normal 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
|
||||||
@@ -2,6 +2,9 @@ security:
|
|||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
|
# 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
|
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||||
providers:
|
providers:
|
||||||
@@ -16,6 +19,10 @@ security:
|
|||||||
dev:
|
dev:
|
||||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
security: false
|
security: false
|
||||||
|
api_public:
|
||||||
|
pattern: ^/api/(activation-tokens|activate|login|docs)(/|$)
|
||||||
|
stateless: true
|
||||||
|
security: false
|
||||||
api:
|
api:
|
||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: true
|
stateless: true
|
||||||
@@ -29,6 +36,8 @@ security:
|
|||||||
access_control:
|
access_control:
|
||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/login, 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 }
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
|
|||||||
@@ -5,12 +5,21 @@
|
|||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
tenant.base_domain: '%env(TENANT_BASE_DOMAIN)%'
|
tenant.base_domain: '%env(TENANT_BASE_DOMAIN)%'
|
||||||
|
app.url: '%env(APP_URL)%'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in this file
|
# default configuration for services in this file
|
||||||
_defaults:
|
_defaults:
|
||||||
autowire: true # Automatically injects dependencies in your services.
|
autowire: true # Automatically injects dependencies in your services.
|
||||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
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
|
# makes classes in src/ available to be used as services
|
||||||
# this creates a service per class whose id is the fully-qualified class name
|
# 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:
|
App\Shared\Infrastructure\Tenant\Command\TenantMigrateCommand:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$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%'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class ActivationTokenGenerated implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ActivationTokenId $tokenId,
|
||||||
|
public string $userId,
|
||||||
|
public string $email,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->tokenId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class ActivationTokenUsed implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ActivationTokenId $tokenId,
|
||||||
|
public string $userId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->tokenId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/src/Administration/Domain/Event/CompteActive.php
Normal file
42
backend/src/Administration/Domain/Event/CompteActive.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event emitted when a user account is activated.
|
||||||
|
*
|
||||||
|
* This event triggers the sending of a confirmation email
|
||||||
|
* and any other side effects related to account activation.
|
||||||
|
*/
|
||||||
|
final readonly class CompteActive implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $userId,
|
||||||
|
public string $email,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public string $role,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
private UuidInterface $aggregateId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->aggregateId;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/Administration/Domain/Event/CompteCreated.php
Normal file
39
backend/src/Administration/Domain/Event/CompteCreated.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event émis lors de la création d'un nouveau compte utilisateur.
|
||||||
|
*/
|
||||||
|
final readonly class CompteCreated implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public UserId $userId,
|
||||||
|
public string $email,
|
||||||
|
public string $role,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->userId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ActivationTokenAlreadyUsedException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function forToken(ActivationTokenId $tokenId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Activation token "%s" has already been used.',
|
||||||
|
$tokenId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ActivationTokenExpiredException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function forToken(ActivationTokenId $tokenId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Activation token "%s" has expired.',
|
||||||
|
$tokenId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ActivationTokenNotFoundException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function withId(ActivationTokenId $tokenId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Activation token with ID "%s" not found.',
|
||||||
|
$tokenId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withTokenValue(string $tokenValue): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Activation token with value "%s" not found.',
|
||||||
|
$tokenValue,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class CompteNonActivableException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le compte "%s" ne peut pas être activé car son statut est "%s".',
|
||||||
|
$userId,
|
||||||
|
$statut->value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function carConsentementManquant(UserId $userId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le compte "%s" ne peut pas être activé : consentement parental manquant.',
|
||||||
|
$userId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class EmailInvalideException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function pourAdresse(string $email): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'L\'adresse email "%s" n\'est pas valide.',
|
||||||
|
$email,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class UserNotFoundException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function withId(UserId $userId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'User with ID "%s" not found.',
|
||||||
|
$userId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withEmail(Email $email): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'User with email "%s" not found.',
|
||||||
|
$email,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\ActivationToken;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\ActivationTokenGenerated;
|
||||||
|
use App\Administration\Domain\Event\ActivationTokenUsed;
|
||||||
|
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||||
|
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ActivationToken extends AggregateRoot
|
||||||
|
{
|
||||||
|
private const int EXPIRATION_DAYS = 7;
|
||||||
|
|
||||||
|
public private(set) ?DateTimeImmutable $usedAt = null;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) ActivationTokenId $id,
|
||||||
|
public private(set) string $tokenValue,
|
||||||
|
public private(set) string $userId,
|
||||||
|
public private(set) string $email,
|
||||||
|
public private(set) TenantId $tenantId,
|
||||||
|
public private(set) string $role,
|
||||||
|
public private(set) string $schoolName,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
public private(set) DateTimeImmutable $expiresAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generate(
|
||||||
|
string $userId,
|
||||||
|
string $email,
|
||||||
|
TenantId $tenantId,
|
||||||
|
string $role,
|
||||||
|
string $schoolName,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
$token = new self(
|
||||||
|
id: ActivationTokenId::generate(),
|
||||||
|
tokenValue: Uuid::uuid4()->toString(),
|
||||||
|
userId: $userId,
|
||||||
|
email: $email,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
role: $role,
|
||||||
|
schoolName: $schoolName,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
expiresAt: $createdAt->modify(sprintf('+%d days', self::EXPIRATION_DAYS)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$token->recordEvent(new ActivationTokenGenerated(
|
||||||
|
tokenId: $token->id,
|
||||||
|
userId: $userId,
|
||||||
|
email: $email,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
occurredOn: $createdAt,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstitute an ActivationToken from storage.
|
||||||
|
* Does NOT record domain events (this is not a new creation).
|
||||||
|
*
|
||||||
|
* @internal For use by Infrastructure layer only
|
||||||
|
*/
|
||||||
|
public static function reconstitute(
|
||||||
|
ActivationTokenId $id,
|
||||||
|
string $tokenValue,
|
||||||
|
string $userId,
|
||||||
|
string $email,
|
||||||
|
TenantId $tenantId,
|
||||||
|
string $role,
|
||||||
|
string $schoolName,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
DateTimeImmutable $expiresAt,
|
||||||
|
?DateTimeImmutable $usedAt,
|
||||||
|
): self {
|
||||||
|
$token = new self(
|
||||||
|
id: $id,
|
||||||
|
tokenValue: $tokenValue,
|
||||||
|
userId: $userId,
|
||||||
|
email: $email,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
role: $role,
|
||||||
|
schoolName: $schoolName,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
expiresAt: $expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$token->usedAt = $usedAt;
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(DateTimeImmutable $at): bool
|
||||||
|
{
|
||||||
|
return $at >= $this->expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isUsed(): bool
|
||||||
|
{
|
||||||
|
return $this->usedAt !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the token can be used (not expired, not already used).
|
||||||
|
* Does NOT mark the token as used - use use() for that after successful activation.
|
||||||
|
*
|
||||||
|
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||||
|
* @throws ActivationTokenExpiredException if token is expired
|
||||||
|
*/
|
||||||
|
public function validateForUse(DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->isUsed()) {
|
||||||
|
throw ActivationTokenAlreadyUsedException::forToken($this->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isExpired($at)) {
|
||||||
|
throw ActivationTokenExpiredException::forToken($this->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the token as used. Should only be called after successful user activation.
|
||||||
|
*
|
||||||
|
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||||
|
* @throws ActivationTokenExpiredException if token is expired
|
||||||
|
*/
|
||||||
|
public function use(DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
$this->validateForUse($at);
|
||||||
|
|
||||||
|
$this->usedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new ActivationTokenUsed(
|
||||||
|
tokenId: $this->id,
|
||||||
|
userId: $this->userId,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\ActivationToken;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class ActivationTokenId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\ConsentementParental;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant le consentement parental.
|
||||||
|
*
|
||||||
|
* Requis pour les utilisateurs mineurs (< 15 ans) conformément au RGPD (NFR-C1).
|
||||||
|
* Le consentement doit être donné par un parent avant que l'élève puisse activer son compte.
|
||||||
|
*/
|
||||||
|
final readonly class ConsentementParental
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $parentId,
|
||||||
|
public string $eleveId,
|
||||||
|
public DateTimeImmutable $dateConsentement,
|
||||||
|
public string $ipAddress,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un nouveau consentement parental horodaté.
|
||||||
|
*/
|
||||||
|
public static function accorder(
|
||||||
|
string $parentId,
|
||||||
|
string $eleveId,
|
||||||
|
DateTimeImmutable $at,
|
||||||
|
string $ipAddress,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
parentId: $parentId,
|
||||||
|
eleveId: $eleveId,
|
||||||
|
dateConsentement: $at,
|
||||||
|
ipAddress: $ipAddress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estPourEleve(string $eleveId): bool
|
||||||
|
{
|
||||||
|
return $this->eleveId === $eleveId;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/Administration/Domain/Model/User/Email.php
Normal file
39
backend/src/Administration/Domain/Model/User/Email.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\User;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\EmailInvalideException;
|
||||||
|
|
||||||
|
use const FILTER_VALIDATE_EMAIL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant une adresse email valide.
|
||||||
|
*
|
||||||
|
* Note: Les property hooks PHP 8.5 ne sont pas compatibles avec readonly.
|
||||||
|
* La validation reste dans le constructeur pour préserver l'immutabilité du Value Object.
|
||||||
|
*/
|
||||||
|
final readonly class Email
|
||||||
|
{
|
||||||
|
public string $value;
|
||||||
|
|
||||||
|
public function __construct(string $value)
|
||||||
|
{
|
||||||
|
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
|
||||||
|
throw EmailInvalideException::pourAdresse($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->value = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return strtolower($this->value) === strtolower($other->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/src/Administration/Domain/Model/User/Role.php
Normal file
74
backend/src/Administration/Domain/Model/User/Role.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\User;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum représentant les rôles utilisateur dans Classeo.
|
||||||
|
*
|
||||||
|
* Hiérarchie RBAC :
|
||||||
|
* - ROLE_SUPER_ADMIN → Accès à tous les établissements
|
||||||
|
* - ROLE_ADMIN → Direction d'un établissement
|
||||||
|
* - ROLE_PROF → Enseignant
|
||||||
|
* - ROLE_VIE_SCOLAIRE → Personnel vie scolaire
|
||||||
|
* - ROLE_SECRETARIAT → Personnel administratif
|
||||||
|
* - ROLE_PARENT → Parent d'élève
|
||||||
|
* - ROLE_ELEVE → Élève
|
||||||
|
*/
|
||||||
|
enum Role: string
|
||||||
|
{
|
||||||
|
case SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
|
||||||
|
case ADMIN = 'ROLE_ADMIN';
|
||||||
|
case PROF = 'ROLE_PROF';
|
||||||
|
case VIE_SCOLAIRE = 'ROLE_VIE_SCOLAIRE';
|
||||||
|
case SECRETARIAT = 'ROLE_SECRETARIAT';
|
||||||
|
case PARENT = 'ROLE_PARENT';
|
||||||
|
case ELEVE = 'ROLE_ELEVE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si ce rôle inclut implicitement un autre rôle (hiérarchie).
|
||||||
|
*/
|
||||||
|
public function inclut(Role $autre): bool
|
||||||
|
{
|
||||||
|
$hierarchie = [
|
||||||
|
self::SUPER_ADMIN->value => [
|
||||||
|
self::ADMIN, self::PROF, self::VIE_SCOLAIRE,
|
||||||
|
self::SECRETARIAT, self::PARENT, self::ELEVE,
|
||||||
|
],
|
||||||
|
self::ADMIN->value => [
|
||||||
|
self::PROF, self::VIE_SCOLAIRE, self::SECRETARIAT,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$rolesInclus = $hierarchie[$this->value] ?? [];
|
||||||
|
|
||||||
|
return in_array($autre, $rolesInclus, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le libellé français du rôle.
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::SUPER_ADMIN => 'Super Administrateur',
|
||||||
|
self::ADMIN => 'Directeur',
|
||||||
|
self::PROF => 'Enseignant',
|
||||||
|
self::VIE_SCOLAIRE => 'Vie Scolaire',
|
||||||
|
self::SECRETARIAT => 'Secrétariat',
|
||||||
|
self::PARENT => 'Parent',
|
||||||
|
self::ELEVE => 'Élève',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si ce rôle nécessite un consentement parental potentiel.
|
||||||
|
*/
|
||||||
|
public function peutEtreMineur(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ELEVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum représentant le statut d'activation d'un compte utilisateur.
|
||||||
|
*/
|
||||||
|
enum StatutCompte: string
|
||||||
|
{
|
||||||
|
case EN_ATTENTE = 'pending'; // Compte créé, en attente d'activation
|
||||||
|
case CONSENTEMENT_REQUIS = 'consent'; // Mineur < 15 ans, en attente consentement parental
|
||||||
|
case ACTIF = 'active'; // Compte activé et utilisable
|
||||||
|
case SUSPENDU = 'suspended'; // Compte temporairement désactivé
|
||||||
|
case ARCHIVE = 'archived'; // Compte archivé (fin de scolarité)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur peut se connecter avec ce statut.
|
||||||
|
*/
|
||||||
|
public function peutSeConnecter(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ACTIF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur peut activer son compte.
|
||||||
|
*/
|
||||||
|
public function peutActiver(): bool
|
||||||
|
{
|
||||||
|
return $this === self::EN_ATTENTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
backend/src/Administration/Domain/Model/User/User.php
Normal file
173
backend/src/Administration/Domain/Model/User/User.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\User;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\CompteActive;
|
||||||
|
use App\Administration\Domain\Event\CompteCreated;
|
||||||
|
use App\Administration\Domain\Exception\CompteNonActivableException;
|
||||||
|
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||||
|
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate Root représentant un utilisateur dans Classeo.
|
||||||
|
*
|
||||||
|
* Un utilisateur appartient à un établissement (tenant) et possède un rôle.
|
||||||
|
* Le cycle de vie du compte passe par plusieurs statuts : création → activation.
|
||||||
|
* Les mineurs (< 15 ans) nécessitent un consentement parental avant activation.
|
||||||
|
*/
|
||||||
|
final class User extends AggregateRoot
|
||||||
|
{
|
||||||
|
public private(set) ?string $hashedPassword = null;
|
||||||
|
public private(set) ?DateTimeImmutable $activatedAt = null;
|
||||||
|
public private(set) ?ConsentementParental $consentementParental = null;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) UserId $id,
|
||||||
|
public private(set) Email $email,
|
||||||
|
public private(set) Role $role,
|
||||||
|
public private(set) TenantId $tenantId,
|
||||||
|
public private(set) string $schoolName,
|
||||||
|
public private(set) StatutCompte $statut,
|
||||||
|
public private(set) ?DateTimeImmutable $dateNaissance,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un nouveau compte utilisateur en attente d'activation.
|
||||||
|
*/
|
||||||
|
public static function creer(
|
||||||
|
Email $email,
|
||||||
|
Role $role,
|
||||||
|
TenantId $tenantId,
|
||||||
|
string $schoolName,
|
||||||
|
?DateTimeImmutable $dateNaissance,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
$user = new self(
|
||||||
|
id: UserId::generate(),
|
||||||
|
email: $email,
|
||||||
|
role: $role,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolName: $schoolName,
|
||||||
|
statut: StatutCompte::EN_ATTENTE,
|
||||||
|
dateNaissance: $dateNaissance,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->recordEvent(new CompteCreated(
|
||||||
|
userId: $user->id,
|
||||||
|
email: (string) $user->email,
|
||||||
|
role: $user->role->value,
|
||||||
|
tenantId: $user->tenantId,
|
||||||
|
occurredOn: $createdAt,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active le compte avec le mot de passe hashé.
|
||||||
|
*
|
||||||
|
* @throws CompteNonActivableException si le compte ne peut pas être activé
|
||||||
|
*/
|
||||||
|
public function activer(
|
||||||
|
string $hashedPassword,
|
||||||
|
DateTimeImmutable $at,
|
||||||
|
ConsentementParentalPolicy $consentementPolicy,
|
||||||
|
): void {
|
||||||
|
if (!$this->statut->peutActiver()) {
|
||||||
|
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si le consentement parental est requis
|
||||||
|
if ($consentementPolicy->estRequis($this->dateNaissance)) {
|
||||||
|
if ($this->consentementParental === null) {
|
||||||
|
throw CompteNonActivableException::carConsentementManquant($this->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hashedPassword = $hashedPassword;
|
||||||
|
$this->statut = StatutCompte::ACTIF;
|
||||||
|
$this->activatedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new CompteActive(
|
||||||
|
userId: (string) $this->id,
|
||||||
|
email: (string) $this->email,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
role: $this->role->value,
|
||||||
|
occurredOn: $at,
|
||||||
|
aggregateId: $this->id->value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre le consentement parental donné par le parent.
|
||||||
|
*/
|
||||||
|
public function enregistrerConsentementParental(ConsentementParental $consentement): void
|
||||||
|
{
|
||||||
|
$this->consentementParental = $consentement;
|
||||||
|
|
||||||
|
// Si le compte était en attente de consentement, passer en attente d'activation
|
||||||
|
if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
|
||||||
|
$this->statut = StatutCompte::EN_ATTENTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si cet utilisateur est mineur et nécessite un consentement parental.
|
||||||
|
*/
|
||||||
|
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
|
||||||
|
{
|
||||||
|
return $policy->estRequis($this->dateNaissance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si le compte est actif et peut se connecter.
|
||||||
|
*/
|
||||||
|
public function peutSeConnecter(): bool
|
||||||
|
{
|
||||||
|
return $this->statut->peutSeConnecter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstitue un User depuis le stockage.
|
||||||
|
*
|
||||||
|
* @internal Pour usage par l'Infrastructure uniquement
|
||||||
|
*/
|
||||||
|
public static function reconstitute(
|
||||||
|
UserId $id,
|
||||||
|
Email $email,
|
||||||
|
Role $role,
|
||||||
|
TenantId $tenantId,
|
||||||
|
string $schoolName,
|
||||||
|
StatutCompte $statut,
|
||||||
|
?DateTimeImmutable $dateNaissance,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
?string $hashedPassword,
|
||||||
|
?DateTimeImmutable $activatedAt,
|
||||||
|
?ConsentementParental $consentementParental,
|
||||||
|
): self {
|
||||||
|
$user = new self(
|
||||||
|
id: $id,
|
||||||
|
email: $email,
|
||||||
|
role: $role,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolName: $schoolName,
|
||||||
|
statut: $statut,
|
||||||
|
dateNaissance: $dateNaissance,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->hashedPassword = $hashedPassword;
|
||||||
|
$user->activatedAt = $activatedAt;
|
||||||
|
$user->consentementParental = $consentementParental;
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/Administration/Domain/Model/User/UserId.php
Normal file
11
backend/src/Administration/Domain/Model/User/UserId.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\User;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class UserId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Policy;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy déterminant si le consentement parental est requis.
|
||||||
|
*
|
||||||
|
* Conformément au RGPD (NFR-C1), le consentement parental est obligatoire
|
||||||
|
* pour les utilisateurs de moins de 15 ans.
|
||||||
|
*/
|
||||||
|
final readonly class ConsentementParentalPolicy
|
||||||
|
{
|
||||||
|
private const int AGE_MAJORITE_NUMERIQUE = 15;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si le consentement parental est requis pour un utilisateur
|
||||||
|
* né à la date spécifiée.
|
||||||
|
*/
|
||||||
|
public function estRequis(?DateTimeImmutable $dateNaissance): bool
|
||||||
|
{
|
||||||
|
if ($dateNaissance === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->calculerAge($dateNaissance) < self::AGE_MAJORITE_NUMERIQUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'âge en années à partir de la date de naissance.
|
||||||
|
*/
|
||||||
|
private function calculerAge(DateTimeImmutable $dateNaissance): int
|
||||||
|
{
|
||||||
|
$now = $this->clock->now();
|
||||||
|
$interval = $now->diff($dateNaissance);
|
||||||
|
|
||||||
|
return $interval->y;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||||
|
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||||
|
|
||||||
|
interface ActivationTokenRepository
|
||||||
|
{
|
||||||
|
public function save(ActivationToken $token): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a token by its unique token value.
|
||||||
|
*/
|
||||||
|
public function findByTokenValue(string $tokenValue): ?ActivationToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a token by its ID.
|
||||||
|
*
|
||||||
|
* @throws \App\Administration\Domain\Exception\ActivationTokenNotFoundException if token does not exist
|
||||||
|
*/
|
||||||
|
public function get(ActivationTokenId $id): ActivationToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a token (after use or for cleanup).
|
||||||
|
*/
|
||||||
|
public function delete(ActivationTokenId $id): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a token by its token value.
|
||||||
|
*/
|
||||||
|
public function deleteByTokenValue(string $tokenValue): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
|
||||||
|
interface UserRepository
|
||||||
|
{
|
||||||
|
public function save(User $user): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \App\Administration\Domain\Exception\UserNotFoundException
|
||||||
|
*/
|
||||||
|
public function get(UserId $id): User;
|
||||||
|
|
||||||
|
public function findByEmail(Email $email): ?User;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
|||||||
'/api/docs.json',
|
'/api/docs.json',
|
||||||
'/api/docs.jsonld',
|
'/api/docs.jsonld',
|
||||||
'/api/contexts',
|
'/api/contexts',
|
||||||
|
'/api/activation-tokens',
|
||||||
|
'/api/activate',
|
||||||
|
'/api/login',
|
||||||
'/_profiler',
|
'/_profiler',
|
||||||
'/_wdt',
|
'/_wdt',
|
||||||
'/_error',
|
'/_error',
|
||||||
|
|||||||
@@ -85,6 +85,18 @@
|
|||||||
"config/packages/lexik_jwt_authentication.yaml"
|
"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": {
|
"phpstan/phpstan": {
|
||||||
"version": "2.1",
|
"version": "2.1",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -166,6 +178,18 @@
|
|||||||
".editorconfig"
|
".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": {
|
"symfony/maker-bundle": {
|
||||||
"version": "1.65",
|
"version": "1.65",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
113
backend/templates/emails/activation_confirmation.html.twig
Normal file
113
backend/templates/emails/activation_confirmation.html.twig
Normal 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
Normal file
220
backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
Normal 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
2
frontend/.gitignore
vendored
@@ -35,3 +35,5 @@ dev-dist/
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
*.local
|
*.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
# Generated test token for E2E tests
|
||||||
|
e2e/.test-token
|
||||||
|
|||||||
187
frontend/e2e/activation.spec.ts
Normal file
187
frontend/e2e/activation.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
52
frontend/e2e/global-setup.ts
Normal file
52
frontend/e2e/global-setup.ts
Normal 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;
|
||||||
22
frontend/e2e/test-utils.ts
Normal file
22
frontend/e2e/test-utils.ts
Normal 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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,7 +73,10 @@ export default tseslint.config(
|
|||||||
process: 'readonly',
|
process: 'readonly',
|
||||||
Promise: 'readonly',
|
Promise: 'readonly',
|
||||||
Set: 'readonly',
|
Set: 'readonly',
|
||||||
Map: 'readonly'
|
Map: 'readonly',
|
||||||
|
Event: 'readonly',
|
||||||
|
SubmitEvent: 'readonly',
|
||||||
|
fetch: 'readonly'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
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 = {
|
const config: PlaywrightTestConfig = {
|
||||||
webServer: {
|
// Always run globalSetup to seed test tokens
|
||||||
command: 'pnpm run build && pnpm run preview',
|
// If backend is not running, tests requiring tokens will be skipped gracefully
|
||||||
port: 4173,
|
globalSetup: './e2e/global-setup.ts',
|
||||||
reuseExistingServer: !process.env.CI
|
webServer: useExternalServer
|
||||||
},
|
? undefined
|
||||||
|
: {
|
||||||
|
command: 'pnpm run build && pnpm run preview',
|
||||||
|
port: 4173,
|
||||||
|
reuseExistingServer: !process.env.CI
|
||||||
|
},
|
||||||
testDir: 'e2e',
|
testDir: 'e2e',
|
||||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:4173',
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'retain-on-failure'
|
video: 'retain-on-failure'
|
||||||
|
|||||||
41
frontend/src/lib/types/activation.ts
Normal file
41
frontend/src/lib/types/activation.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
649
frontend/src/routes/activate/[token]/+page.svelte
Normal file
649
frontend/src/routes/activate/[token]/+page.svelte
Normal 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>
|
||||||
257
frontend/src/routes/login/+page.svelte
Normal file
257
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user