Wire Doctrine's default connection to the tenant database resolved from the subdomain for HTTP requests and tenant-scoped Messenger messages while keeping master-only services on the master connection.
This removes the production inconsistency where demo data, migrations and tenant commands used the tenant database but the web runtime still read from master.
L'admin pouvait attribuer une couleur à chaque matière, mais cette
couleur n'était utilisée que dans la vue admin de l'emploi du temps.
Les APIs élève et parent ne renvoyaient pas cette information, ce qui
donnait un affichage générique (gris/bleu) pour tous les créneaux.
L'API renvoie désormais subjectColor dans chaque créneau, et les vues
jour/semaine/widget/détails affichent la bordure colorée correspondante.
Le marqueur "Prochain cours" conserve sa priorité visuelle via une
surcharge CSS variable.
Le handler ChangeStudentClass exigeait une affectation existante pour
l'année scolaire en cours avant de pouvoir changer la classe. Un élève
créé sans ClassAssignment (import direct, année précédente) provoquait
une erreur "Élève non trouvé" au lieu d'être simplement affecté.
Le handler crée désormais une nouvelle affectation quand aucune n'existe,
et l'erreur de changement de classe s'affiche dans la modale au lieu de
la page principale.
Les parents avaient accès au lien "Emploi du temps" dans la navigation,
mais le dashboard n'affichait aucune donnée réelle : la section EDT
restait un placeholder vide ("L'emploi du temps sera disponible...").
Cette implémentation connecte le dashboard parent aux vrais endpoints API
(GET /api/me/children/{childId}/schedule/day|week/{date} et le résumé
multi-enfants), affiche le ScheduleWidget avec le prochain cours mis en
évidence (AC1), permet de cliquer sur chaque enfant dans le résumé pour
voir son EDT détaillé (AC2), et met en cache les endpoints parent dans le
Service Worker pour le mode offline (AC5).
Le handler backend est optimisé pour ne résoudre que l'enfant demandé
(via childId optionnel dans la query) au lieu de tous les enfants à chaque
appel, et les fonctions utilitaires dupliquées (formatSyncDate, timezone)
sont factorisées.
Lors d'un drag & drop d'un créneau vers un autre jour de la semaine,
le nouveau créneau devenait invisible car recurrenceStart était fixé à
la date d'occurrence (le jour source). Si le jour cible tombait avant
cette date dans la semaine, isActiveOnDate retournait false.
recurrenceStart est maintenant fixé au lundi de la semaine d'occurrence,
ce qui garantit la visibilité du créneau dès la semaine du déplacement.
Quand une classe n'avait aucune affectation enseignant-matière, les
selects de la modale de création de créneau affichaient tous les
enseignants et toutes les matières au lieu d'une liste vide. Cela
permettait de soumettre des combinaisons invalides, produisant un
message d'erreur avec des UUID incompréhensibles.
Les dropdowns n'affichent plus que les enseignants/matières effectivement
affectés à la classe sélectionnée. Le message d'erreur backend est
reformulé sans UUID pour le cas où la validation frontend serait
contournée.
Lorsqu'un admin créait un élève de moins de 15 ans avec une date de
naissance, le compte ne pouvait pas être activé car le consentement
parental RGPD n'avait jamais été enregistré — aucun mécanisme ne le
permettait dans le parcours admin.
Ajout d'une case « Consentement parental obtenu » dans le formulaire
de création d'élève, affichée conditionnellement quand la date de
naissance indique un âge < 15 ans. L'admin confirme que l'établissement
a recueilli le consentement, qui est alors enregistré côté backend
lors de la création du compte.
Les élèves n'avaient aucun moyen de voir leur emploi du temps
depuis l'application. Cette fonctionnalité ajoute une page dédiée
avec deux modes de visualisation (jour et semaine), la navigation
temporelle, et le détail des cours au tap.
Le backend résout l'EDT de l'élève en chaînant : affectation classe →
créneaux récurrents + exceptions + calendrier scolaire → enrichissement
des noms (matières/enseignants). Le frontend utilise un cache offline
(Workbox NetworkFirst) pour rester consultable hors connexion.
Les administrateurs devaient recréer manuellement l'emploi du temps chaque
semaine. Cette implémentation introduit un système de récurrence hebdomadaire
avec gestion des exceptions par occurrence, permettant de modifier ou annuler
un cours spécifique sans affecter les autres semaines.
Le ScheduleResolver calcule dynamiquement l'EDT réel en combinant les créneaux
récurrents, les exceptions ponctuelles et le calendrier scolaire (vacances/fériés).
Le beforeAll du test réinitialise le statut de l'utilisateur cible via
SQL direct (dbal:run-sql), mais le CachedUserRepository (cache-aside
Redis) conserve l'ancienne entrée avec statut "suspended". Quand le
BlockUserHandler charge l'utilisateur, il lit le cache Redis périmé et
refuse le blocage car le compte apparaît déjà suspendu.
Le clearCache() ne vidait que paginated_queries.cache. En ajoutant
users.cache, le cache Redis de l'utilisateur est invalidé et le
handler lit bien le statut "active" depuis PostgreSQL.
L'administration a besoin de construire et maintenir les emplois du temps
hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont
pas en conflit (même créneau, classes différentes) et que les affectations
enseignant-matière-classe sont respectées.
Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot),
la détection de conflits (classe, enseignant, salle) avec possibilité de
forcer, la validation des affectations côté serveur (AC2), l'intégration
calendrier pour les jours bloqués, une vue mobile-first avec onglets jour
par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
Le commit 23dd717 a introduit un cache Redis (paginated_queries.cache)
pour les requêtes paginées. Les tests E2E qui modifient les données via
SQL direct (beforeAll, cleanup) contournent la couche applicative et ne
déclenchent pas l'invalidation du cache, provoquant des données obsolètes.
De plus, plusieurs problèmes d'isolation entre tests ont été découverts :
- Les tests classes.spec.ts supprimaient les données d'autres specs via
DELETE FROM school_classes sans nettoyer les FK dépendantes
- Les tests user-blocking utilisaient des emails partagés entre les
projets Playwright (chromium/firefox/webkit) exécutés en parallèle,
causant des race conditions sur l'état du compte utilisateur
- Le handler NotifyTeachersPedagogicalDayHandler s'exécutait de manière
synchrone, bloquant la réponse HTTP pendant l'envoi des emails
- La sélection d'un enseignant remplaçant effaçait l'autre dropdown car
{#if} supprimait l'option sélectionnée du DOM
Corrections appliquées :
- Ajout de cache:pool:clear après chaque modification SQL directe
- Nettoyage des FK dépendantes avant les DELETE (classes, subjects)
- Emails uniques par projet navigateur pour éviter les race conditions
- Routage de JourneePedagogiqueAjoutee vers le transport async
- Remplacement de {#if} par disabled sur les selects de remplacement
- Recherche par nom sur la page classes pour gérer la pagination
- Patterns toPass() pour la fiabilité Firefox sur les color pickers
Les listes paginées (utilisateurs, classes, matières, affectations,
invitations parents, droits à l'image) effectuaient des requêtes SQL
complètes à chaque chargement de page, sans aucun cache. Sur les
établissements avec plusieurs centaines d'enregistrements, cela causait
des temps de réponse perceptibles et une charge inutile sur PostgreSQL.
Cette refactorisation introduit un cache tag-aware (Redis en prod,
filesystem en dev) avec invalidation événementielle, et extrait les
requêtes de lecture dans des ports Application / implémentations DBAL
conformes à l'architecture hexagonale. Un middleware Messenger garantit
l'invalidation synchrone du cache même pour les événements routés en
asynchrone (envoi d'emails), évitant ainsi toute donnée périmée côté UI.
Le menu d'administration contenait 13 liens à plat dans le header, ce qui
débordait sur desktop et rendait le drawer mobile trop long à scanner.
Les liens sont maintenant regroupés en 4 catégories (Personnes, Organisation,
Année scolaire, Paramètres) avec des dropdowns au survol sur desktop et des
accordéons repliables dans le drawer mobile. Le nombre d'éléments visibles
passe de 13 à 5 (1 lien direct + 4 catégories), la catégorie active
s'auto-déplie dans le menu mobile.
Les administrateurs ont besoin d'un moyen simple pour inviter les parents
à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes
d'invitation uniques (8 caractères alphanumériques) avec une validité de
48h, de les envoyer par email, et de les activer via une page publique
dédiée qui crée automatiquement le compte parent.
L'interface d'administration offre l'envoi unitaire et en masse, le renvoi,
le filtrage par statut, ainsi que la visualisation de l'état de chaque
invitation (en attente, activée, expirée).
L'établissement a besoin d'importer en masse ses enseignants depuis les
exports des logiciels de vie scolaire (Pronote, EDT, etc.), comme c'est
déjà possible pour les élèves. Le wizard en 4 étapes (upload → mapping
→ aperçu → import) réutilise l'architecture de l'import élèves tout en
ajoutant la gestion des matières et des classes enseignées.
Corrections de la review #2 intégrées :
- La commande ImportTeachersCommand est routée en async via Messenger
pour ne pas bloquer la requête HTTP sur les gros fichiers.
- Le handler est protégé par un try/catch Throwable pour marquer le
batch en échec si une erreur inattendue survient, évitant qu'il
reste bloqué en statut "processing".
- Les domain events (UtilisateurInvite) sont dispatchés sur l'event
bus après chaque création d'utilisateur, déclenchant l'envoi des
emails d'invitation.
- L'option "mettre à jour les enseignants existants" (AC5) permet de
choisir entre ignorer ou mettre à jour nom/prénom et ajouter les
affectations manquantes pour les doublons détectés par email.
Sur Firefox, l'hydration Svelte peut prendre plus de temps que sur
Chromium. L'assertion tabs.nth(1).toHaveAttribute manquait de timeout
dans periods.spec.ts, et le formulaire de login n'était pas
complètement hydraté avant interaction dans admin-responsive-nav.spec.ts.
L'import manuel élève par élève est fastidieux pour les établissements
qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes
(upload → mapping → preview → confirmation) permet de traiter un
fichier complet en une seule opération, avec détection automatique
du format (Pronote, École Directe) et validation avant import.
L'import est traité de manière asynchrone via Messenger pour ne pas
bloquer l'interface, avec suivi de progression en temps réel et
réutilisation des mappings entre imports successifs.
Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un
élève en cours d'année sans passer par un import CSV. Cette fonctionnalité
pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui
sera réutilisé par l'import CSV en masse (Story 3.1).
L'email est désormais optionnel pour les élèves : si fourni, une invitation
est envoyée (User::inviter) ; sinon l'élève est créé avec le statut
INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur
et l'affectation à la classe sont atomiques (transaction DBAL).
Côté frontend, la page /admin/students offre liste paginée, recherche,
filtrage par classe, création via modale (avec détection de doublons
côté serveur), et changement de classe avec optimistic update.
Le test user-blocking-session échouait sur Firefox car Playwright
détruit le navigateur au timeout, puis le bloc finally tentait de
fermer des contextes déjà détruits. Les appels browserContext.close()
sont désormais protégés par .catch().
Le test user-blocking ne réinitialisait pas l'état du compte cible
entre les exécutions, ce qui faisait échouer la recherche du bouton
"Bloquer" si l'utilisateur était resté suspendu d'une exécution
précédente.
Les administrateurs peuvent désormais configurer l'identité visuelle
de leur établissement : upload d'un logo (PNG/JPG, redimensionné
automatiquement via Imagick) et choix d'une couleur principale
appliquée aux boutons et à la navigation.
La couleur est validée côté client et serveur pour garantir la
conformité WCAG AA (contraste ≥ 4.5:1 sur fond blanc). Les
personnalisations sont injectées dynamiquement via CSS variables
et visibles immédiatement après sauvegarde.
Chaque action inline (block, unblock, renvoi d'invitation, modification
de période, fin de remplacement, ajout de journée pédagogique) déclenchait
un rechargement complet de la liste via loadXxx(). Cela causait un reset
du scroll, un temps d'attente visible et des requêtes réseau inutiles.
Le pattern de mise à jour locale introduit dans la story Droit à l'image
est généralisé aux 4 pages admin restantes, en extrayant explicitement
les champs utiles de la réponse API pour éviter la pollution JSON-LD.
Les administrateurs et enseignants ont besoin de consulter et gérer
les autorisations de droit à l'image des élèves pour respecter
la réglementation lors de publications contenant des photos (FR82).
Cette fonctionnalité ajoute une page dédiée avec liste filtrable
par statut, modification individuelle via dropdown, export CSV
avec BOM UTF-8 pour Excel, et préparation du système d'avertissement
avant publication (query/handler prêts, intégration à faire quand
le module publication existera).
Le filtrage par classe (AC2) est bloqué en attente d'une table
d'affectation élève↔classe qui n'existe pas encore.
Git ne tracke pas les répertoires vides, ce qui fait que la CI échoue
au cache:clear avec "Specified non-existing directory ... as Doctrine
mapping source". Le .gitkeep garantit que le répertoire existe après
un clone frais.
Les administrateurs d'établissement avaient besoin de gérer le calendrier
scolaire (FR80) pour que l'EDT et les devoirs respectent automatiquement
les jours non travaillés. Sans cette configuration centralisée, chaque
module devait gérer indépendamment les contraintes de dates.
Le calendrier s'appuie sur l'API data.education.gouv.fr pour importer
les vacances officielles par zone (A/B/C) et calcule les 11 jours fériés
français (dont les fêtes mobiles liées à Pâques). Les enseignants sont
notifiés par email lors de l'ajout d'une journée pédagogique. Un query
IsSchoolDay et une validation des dates d'échéance de devoirs permettent
aux autres modules de s'intégrer sans couplage direct.
Le super admin (table super_admins, master DB) ne pouvait pas se connecter
via /api/login car ce firewall n'utilisait que le provider tenant. De même,
le JWT n'était pas enrichi pour les super admins, l'endpoint /api/me/roles
les rejetait, et le frontend redirigeait systématiquement vers /dashboard.
Un chain provider (super_admin + tenant) résout l'authentification,
le JwtPayloadEnricher et MyRolesProvider gèrent désormais les deux types
d'utilisateurs, et le frontend redirige selon le rôle après login.
Permet aux administrateurs de désigner un enseignant remplaçant pour
un autre enseignant absent, sur des classes et matières précises, pour
une période donnée. Le dashboard enseignant affiche les remplacements
actifs avec les noms de classes/matières au lieu des identifiants bruts.
Inclut les corrections de la code review :
- Requête findActiveByTenant qui excluait les remplacements en cours
mais incluait les futurs (manquait start_date <= :at)
- Validation tenant et rôle enseignant dans le handler de désignation
pour empêcher l'affectation cross-tenant ou de non-enseignants
- Validation structurée du payload classes (Assert\Collection + UUID)
pour éviter les erreurs serveur sur payloads malformés
- API replaced-classes enrichie avec les noms classe/matière
Couverture des processors (RefreshToken, RequestPasswordReset,
ResetPassword, SwitchRole, UpdateUserRoles), des query handlers
(HasGradesInPeriod, HasStudentsInClass), des messaging handlers
(SendActivationConfirmation, SendPasswordResetEmail), et côté
frontend des modules auth, roles, monitoring, types et E2E tokens.
Les utilisateurs étaient stockés uniquement dans Redis (CacheUserRepository),
ce qui exposait à une perte totale des comptes en cas de restart Redis,
FLUSHDB ou perte du volume Docker. Les tables student_guardians et
teacher_assignments référençaient des user IDs sans FK réelle.
PostgreSQL devient la source de vérité via DoctrineUserRepository (DBAL,
upsert ON CONFLICT). CachedUserRepository décore l'interface existante
avec le pattern cache-aside : lectures Redis d'abord → miss → PostgreSQL
→ populate Redis ; écritures PostgreSQL d'abord → mise à jour Redis.
Si Redis est indisponible, l'application continue via PostgreSQL seul.
Une commande de migration (app:migrate-users-to-postgres) permet de copier
les données Redis existantes vers PostgreSQL de manière idempotente.
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient
toutes les données d'un coup, ce qui dégradait l'expérience avec un volume
croissant. La pagination côté serveur existait dans la config API Platform
mais aucun Provider ne l'exploitait.
Cette implémentation ajoute la pagination serveur (30 items/page, max 100)
avec recherche textuelle sur toutes les sections, des composants frontend
réutilisables (Pagination + SearchInput avec debounce), et la synchronisation
URL pour le partage de liens filtrés.
Les Query valident leurs paramètres (clamp page/limit, trim search) pour
éviter les abus. Les affectations utilisent des lookup maps pour résoudre
les noms sans N+1 queries. Les pages admin gèrent les race conditions
via AbortController.
Permet aux administrateurs d'associer un enseignant à une classe pour une
matière donnée au sein d'une année scolaire. Cette brique est nécessaire
pour construire les emplois du temps et les carnets de notes par la suite.
Le modèle impose l'unicité du triplet enseignant × classe × matière par
année scolaire, avec réactivation automatique d'une affectation retirée
plutôt que duplication. L'isolation multi-tenant est garantie au niveau
du repository (findById/get filtrent par tenant_id).
Les helpers loginAs* utilisaient un pattern séquentiel (click → wait)
qui crée une race condition : la navigation peut se terminer avant que
le listener soit en place. Firefox sur CI est particulièrement sensible.
Le fix remplace ce pattern par Promise.all([waitForURL, click]) dans
les 14 fichiers E2E concernés, alignant le code sur le pattern robuste
déjà utilisé dans login.spec.ts.
L'ensemble du frontend utilisait un mix de @media (max-width) desktop-first
et @media (min-width) mobile-first, en contradiction avec la spec UX qui
impose une stratégie mobile-first radicale (78% des parents/élèves sur mobile).
Cette conversion uniformise les 8 fichiers restants vers @media (min-width)
avec les breakpoints de la spec UX (sm: 640px, md: 768px) pour garantir
une expérience progressive enhancement cohérente.
Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes,
emploi du temps, devoirs). Cela nécessite un lien formalisé entre le
compte parent et le compte élève, géré par les administrateurs.
Le lien est établi soit manuellement via l'interface d'administration,
soit automatiquement lors de l'activation du compte parent lorsque
l'invitation inclut un élève cible. Ce lien conditionne l'accès aux
données scolaires de l'enfant (autorisations vérifiées par un voter
dédié).
Les utilisateurs Classeo étaient limités à un seul rôle, alors que
dans la réalité scolaire un directeur peut aussi être enseignant,
ou un parent peut avoir un rôle vie scolaire. Cette limitation
obligeait à créer des comptes distincts par fonction.
Le modèle User supporte désormais plusieurs rôles simultanés avec
basculement via le header. L'admin peut attribuer/retirer des rôles
depuis l'interface de gestion, avec des garde-fous : pas d'auto-
destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut
attribuer SUPER_ADMIN), vérification du statut actif pour le
switch de rôle, et TTL explicite sur le cache de rôle actif.
Les événements métier (emails d'invitation, reset password, activation)
bloquaient la réponse API en étant traités de manière synchrone. Ce commit
route ces événements vers un transport AMQP asynchrone avec un worker
dédié, garantissant des réponses API rapides et une gestion robuste des
échecs.
Le retry utilise une stratégie Fibonacci (1s, 1s, 2s, 3s, 5s, 8s, 13s)
qui offre un bon compromis entre réactivité et protection des services
externes. Les messages qui épuisent leurs tentatives arrivent dans une
dead-letter queue Doctrine avec alerte email à l'admin.
La commande console CreateTestActivationTokenCommand détecte désormais
les comptes déjà actifs et génère un token de réinitialisation de mot
de passe au lieu d'un token d'activation, évitant une erreur bloquante
lors de la ré-invitation par un admin.
Permet aux administrateurs d'un établissement de gérer le cycle de vie
des comptes utilisateurs : inviter de nouveaux membres, bloquer/débloquer
des comptes actifs, et renvoyer des invitations en attente.
Chaque mutation vérifie l'appartenance au tenant courant pour empêcher
les accès cross-tenant. Le blocage est restreint aux comptes actifs
uniquement et un administrateur ne peut pas bloquer son propre compte.
Les comptes suspendus reçoivent une erreur 403 spécifique au login
(sans déclencher l'escalade du rate limiting) et les tentatives sont
tracées dans les métriques Prometheus.
Les établissements scolaires utilisent des systèmes d'évaluation variés
(notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application
imposait implicitement le mode notes /20, ce qui ne correspondait pas
à la réalité pédagogique de nombreuses écoles.
Cette configuration permet à chaque établissement de choisir son mode
de notation par année scolaire, avec verrouillage automatique dès que
des notes ont été saisies pour éviter les incohérences. Le Score Sérénité
adapte ses pondérations selon le mode choisi (les compétences sont
converties via un mapping, le mode sans notes exclut la composante notes).
L'administration d'un établissement nécessite de découper l'année
scolaire en trimestres ou semestres avant de pouvoir saisir les notes
et générer les bulletins.
Ce module permet de configurer les périodes par année scolaire
(current/previous/next résolus en UUID v5 déterministes), de modifier
les dates individuelles avec validation anti-chevauchement, et de
consulter la période en cours avec le décompte des jours restants.
Les dates par défaut de février s'adaptent aux années bissextiles.
Le repository utilise UPSERT transactionnel pour garantir l'intégrité
lors du changement de mode (trimestres ↔ semestres). Les domain events
de Subject sont étendus pour couvrir toutes les mutations (code,
couleur, description) en plus du renommage.
Les établissements ont besoin de définir leur référentiel de matières
pour pouvoir ensuite les associer aux enseignants et aux classes.
Cette fonctionnalité permet aux administrateurs de créer, modifier et
archiver les matières avec leurs propriétés (nom, code court, couleur).
L'architecture suit le pattern DDD avec des Value Objects utilisant
les property hooks PHP 8.5 pour garantir l'immutabilité et la validation.
L'isolation multi-tenant est assurée par vérification dans les handlers.
Permet aux administrateurs de créer, modifier et supprimer des classes
pour organiser les élèves par niveau. L'archivage soft-delete préserve
l'historique tout en masquant les classes obsolètes.
Inclut la validation des noms (2-50 caractères), les niveaux scolaires
du CP à la Terminale, et les contrôles d'accès par rôle.
Permet aux parents de visualiser une démo du Score Sérénité dès leur
première connexion, avant même que les données réelles soient disponibles.
Les autres rôles (enseignant, élève, admin) ont également leur dashboard
adapté avec des sections placeholder.
La landing page redirige automatiquement vers /dashboard si l'utilisateur
est déjà authentifié, offrant un accès direct au tableau de bord.
Story 1.7 - Implémente un système complet d'audit trail pour tracer
toutes les actions sensibles (authentification, modifications de données,
exports) avec immuabilité garantie par PostgreSQL.
Fonctionnalités principales:
- Table audit_log append-only avec contraintes PostgreSQL (RULE)
- AuditLogger centralisé avec injection automatique du contexte
- Correlation ID pour traçabilité distribuée (HTTP + async)
- Handlers pour événements d'authentification
- Commande d'archivage des logs anciens
- Pas de PII dans les logs (emails/IPs hashés)
Infrastructure:
- Middlewares Messenger pour propagation du Correlation ID
- HTTP middleware pour génération/propagation du Correlation ID
- Support multi-tenant avec TenantResolver
Permet aux utilisateurs de visualiser et gérer leurs sessions actives
sur différents appareils, avec la possibilité de révoquer des sessions
à distance en cas de suspicion d'activité non autorisée.
Fonctionnalités :
- Liste des sessions actives avec métadonnées (appareil, navigateur, localisation)
- Identification de la session courante
- Révocation individuelle d'une session
- Révocation de toutes les autres sessions
- Déconnexion avec nettoyage des cookies sur les deux chemins (legacy et actuel)
Sécurité :
- Cache frontend scopé par utilisateur pour éviter les fuites entre comptes
- Validation que le refresh token appartient à l'utilisateur JWT authentifié
- TTL des sessions Redis aligné sur l'expiration du refresh token
- Événements d'audit pour traçabilité (SessionInvalidee, ToutesSessionsInvalidees)
@see Story 1.6 - Gestion des sessions
Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5):
Backend:
- Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique
- Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP)
- Endpoint POST /api/password/reset avec validation token
- Templates email (demande + confirmation)
- Repository Redis avec TTL 2h pour distinguer expiré/invalide
Frontend:
- Page /mot-de-passe-oublie avec message générique (anti-énumération)
- Page /reset-password/[token] avec validation temps réel des critères
- Gestion erreurs: token invalide, expiré, déjà utilisé
Tests:
- 14 tests unitaires PasswordResetToken
- 7 tests unitaires RequestPasswordResetHandler
- 7 tests unitaires ResetPasswordHandler
- Tests E2E Playwright pour le flux complet