From ba80e8cb578ff966823139f85ccfe0e64dee6a20 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Fri, 6 Mar 2026 11:06:28 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20Permettre=20l'activation=20des=20comptes?= =?UTF-8?q?=20=C3=A9l=C3=A8ves=20de=20moins=20de=2015=20ans=20cr=C3=A9?= =?UTF-8?q?=C3=A9s=20par=20l'admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../CreateStudent/CreateStudentCommand.php | 2 + .../CreateStudent/CreateStudentHandler.php | 21 +++++++ .../Api/Processor/CreateStudentProcessor.php | 12 ++++ .../Api/Resource/StudentResource.php | 2 + .../CreateStudentHandlerTest.php | 2 + .../src/lib/features/students/api/students.ts | 4 +- .../src/routes/admin/students/+page.svelte | 61 ++++++++++++++++++- 7 files changed, 101 insertions(+), 3 deletions(-) diff --git a/backend/src/Administration/Application/Command/CreateStudent/CreateStudentCommand.php b/backend/src/Administration/Application/Command/CreateStudent/CreateStudentCommand.php index 2f8b30a..bcc561b 100644 --- a/backend/src/Administration/Application/Command/CreateStudent/CreateStudentCommand.php +++ b/backend/src/Administration/Application/Command/CreateStudent/CreateStudentCommand.php @@ -16,6 +16,8 @@ final readonly class CreateStudentCommand public ?string $email = null, public ?string $dateNaissance = null, public ?string $studentNumber = null, + public bool $parentalConsent = false, + public ?string $consentRecordedBy = null, ) { } } diff --git a/backend/src/Administration/Application/Command/CreateStudent/CreateStudentHandler.php b/backend/src/Administration/Application/Command/CreateStudent/CreateStudentHandler.php index 18cad3c..7a28d52 100644 --- a/backend/src/Administration/Application/Command/CreateStudent/CreateStudentHandler.php +++ b/backend/src/Administration/Application/Command/CreateStudent/CreateStudentHandler.php @@ -7,11 +7,13 @@ namespace App\Administration\Application\Command\CreateStudent; use App\Administration\Domain\Exception\ClasseNotFoundException; use App\Administration\Domain\Exception\EmailDejaUtiliseeException; use App\Administration\Domain\Model\ClassAssignment\ClassAssignment; +use App\Administration\Domain\Model\ConsentementParental\ConsentementParental; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; use App\Administration\Domain\Model\SchoolClass\ClassId; 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\Policy\ConsentementParentalPolicy; use App\Administration\Domain\Repository\ClassAssignmentRepository; use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Domain\Repository\UserRepository; @@ -31,6 +33,7 @@ final readonly class CreateStudentHandler private ClassRepository $classRepository, private Connection $connection, private Clock $clock, + private ConsentementParentalPolicy $consentementPolicy, ) { } @@ -94,6 +97,24 @@ final readonly class CreateStudentHandler studentNumber: $command->studentNumber, ); + // Enregistrer le consentement parental si fourni par l'admin + if ($command->parentalConsent && $command->consentRecordedBy !== null) { + $dateNaissance = $command->dateNaissance !== null + ? new DateTimeImmutable($command->dateNaissance) + : null; + + if ($this->consentementPolicy->estRequis($dateNaissance)) { + $user->enregistrerConsentementParental( + ConsentementParental::accorder( + parentId: $command->consentRecordedBy, + eleveId: (string) $user->id, + at: $now, + ipAddress: 'admin-creation', + ), + ); + } + } + $this->userRepository->save($user); // Affecter à la classe diff --git a/backend/src/Administration/Infrastructure/Api/Processor/CreateStudentProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/CreateStudentProcessor.php index 7ef87cb..3cf5333 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/CreateStudentProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/CreateStudentProcessor.php @@ -13,10 +13,12 @@ use App\Administration\Domain\Exception\EmailDejaUtiliseeException; use App\Administration\Domain\Model\SchoolClass\ClassId; use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Infrastructure\Api\Resource\StudentResource; +use App\Administration\Infrastructure\Security\SecurityUser; use App\Administration\Infrastructure\Security\StudentVoter; use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; use App\Shared\Infrastructure\Tenant\TenantContext; use Override; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; @@ -37,6 +39,7 @@ final readonly class CreateStudentProcessor implements ProcessorInterface private MessageBusInterface $eventBus, private AuthorizationCheckerInterface $authorizationChecker, private CurrentAcademicYearResolver $academicYearResolver, + private Security $security, ) { } @@ -60,6 +63,13 @@ final readonly class CreateStudentProcessor implements ProcessorInterface ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); try { + $adminUserId = null; + $securityUser = $this->security->getUser(); + + if ($securityUser instanceof SecurityUser) { + $adminUserId = $securityUser->userId(); + } + $command = new CreateStudentCommand( tenantId: $tenantId, schoolName: $tenantConfig->subdomain, @@ -70,6 +80,8 @@ final readonly class CreateStudentProcessor implements ProcessorInterface email: $data->email, dateNaissance: $data->dateNaissance, studentNumber: $data->studentNumber, + parentalConsent: $data->parentalConsent, + consentRecordedBy: $adminUserId, ); $user = ($this->handler)($command); diff --git a/backend/src/Administration/Infrastructure/Api/Resource/StudentResource.php b/backend/src/Administration/Infrastructure/Api/Resource/StudentResource.php index b6c3dc0..15a4553 100644 --- a/backend/src/Administration/Infrastructure/Api/Resource/StudentResource.php +++ b/backend/src/Administration/Infrastructure/Api/Resource/StudentResource.php @@ -72,6 +72,8 @@ final class StudentResource #[Assert\Regex(pattern: '/^[A-Za-z0-9]{11}$/', message: 'L\'INE doit contenir exactement 11 caractères alphanumériques.')] public ?string $studentNumber = null; + public bool $parentalConsent = false; + public static function fromDto(StudentWithClassDto $dto): self { $resource = new self(); diff --git a/backend/tests/Unit/Administration/Application/Command/CreateStudent/CreateStudentHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/CreateStudent/CreateStudentHandlerTest.php index 5fe7ff4..16cadda 100644 --- a/backend/tests/Unit/Administration/Application/Command/CreateStudent/CreateStudentHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Command/CreateStudent/CreateStudentHandlerTest.php @@ -189,6 +189,7 @@ final class CreateStudentHandlerTest extends TestCase $this->classRepository, $connection, $this->clock, + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), ); $command = $this->createCommand(); @@ -278,6 +279,7 @@ final class CreateStudentHandlerTest extends TestCase $this->classRepository, $this->connection, $this->clock, + new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock), ); } diff --git a/frontend/src/lib/features/students/api/students.ts b/frontend/src/lib/features/students/api/students.ts index 0e4d382..09ff7fc 100644 --- a/frontend/src/lib/features/students/api/students.ts +++ b/frontend/src/lib/features/students/api/students.ts @@ -40,6 +40,7 @@ export interface CreateStudentData { email?: string | undefined; dateNaissance?: string | undefined; studentNumber?: string | undefined; + parentalConsent?: boolean | undefined; } /** @@ -93,7 +94,7 @@ export async function fetchClasses(): Promise { */ export async function createStudent(studentData: CreateStudentData): Promise { const apiUrl = getApiBaseUrl(); - const body: Record = { + const body: Record = { firstName: studentData.firstName, lastName: studentData.lastName, classId: studentData.classId @@ -101,6 +102,7 @@ export async function createStudent(studentData: CreateStudentData): Promise(null); let duplicateConfirmed = $state(false); let createAnother = $state(false); + let needsParentalConsent = $derived(() => { + if (!newDateNaissance) return false; + const birth = new Date(newDateNaissance + 'T00:00:00'); + const now = new Date(); + let age = now.getFullYear() - birth.getFullYear(); + const monthDiff = now.getMonth() - birth.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) { + age--; + } + return age < 15; + }); + // Change class modal state let showChangeClassModal = $state(false); let changeClassTarget = $state(null); @@ -176,6 +189,7 @@ newClassId = ''; newDateNaissance = ''; newStudentNumber = ''; + newParentalConsent = false; duplicateWarning = null; duplicateConfirmed = false; createAnother = false; @@ -224,6 +238,7 @@ async function handleCreateStudent() { if (!newFirstName.trim() || !newLastName.trim() || !newClassId) return; + if (needsParentalConsent() && !newParentalConsent) return; if (await checkDuplicate()) return; @@ -236,7 +251,8 @@ classId: newClassId, email: newEmail.trim() ? newEmail.trim().toLowerCase() : undefined, dateNaissance: newDateNaissance || undefined, - studentNumber: newStudentNumber.trim() || undefined + studentNumber: newStudentNumber.trim() || undefined, + parentalConsent: newParentalConsent || undefined }); // Optimistic update: only add to list if matching current filters @@ -375,7 +391,8 @@ newLastName.trim() !== '' && newClassId !== '' && !isSubmitting && - (!newStudentNumber.trim() || isValidINE(newStudentNumber.trim())) + (!newStudentNumber.trim() || isValidINE(newStudentNumber.trim())) && + (!needsParentalConsent() || newParentalConsent) ); @@ -609,6 +626,19 @@ + {#if needsParentalConsent()} + + {/if} + {#if duplicateWarning}

{duplicateWarning}

@@ -1160,6 +1190,33 @@ color: #9ca3af; } + .consent-notice { + padding: 0.75rem; + background: #fef3c7; + border: 1px solid #fcd34d; + border-radius: 0.5rem; + } + + .consent-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; + color: #92400e; + cursor: pointer; + } + + .consent-label input[type='checkbox'] { + width: 1rem; + height: 1rem; + accent-color: #f59e0b; + } + + .consent-notice .field-hint { + margin-left: 1.5rem; + color: #92400e; + } + .field-error { display: block; margin-top: 0.25rem;