fix: Permettre l'activation des comptes élèves de moins de 15 ans créés par l'admin

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.
This commit is contained in:
2026-03-06 11:06:28 +01:00
parent 36ceefb625
commit ba80e8cb57
7 changed files with 101 additions and 3 deletions

View File

@@ -16,6 +16,8 @@ final readonly class CreateStudentCommand
public ?string $email = null, public ?string $email = null,
public ?string $dateNaissance = null, public ?string $dateNaissance = null,
public ?string $studentNumber = null, public ?string $studentNumber = null,
public bool $parentalConsent = false,
public ?string $consentRecordedBy = null,
) { ) {
} }
} }

View File

@@ -7,11 +7,13 @@ namespace App\Administration\Application\Command\CreateStudent;
use App\Administration\Domain\Exception\ClasseNotFoundException; use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException; use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment; 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\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId; use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\Email; use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role; use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User; use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\ClassAssignmentRepository; use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\UserRepository; use App\Administration\Domain\Repository\UserRepository;
@@ -31,6 +33,7 @@ final readonly class CreateStudentHandler
private ClassRepository $classRepository, private ClassRepository $classRepository,
private Connection $connection, private Connection $connection,
private Clock $clock, private Clock $clock,
private ConsentementParentalPolicy $consentementPolicy,
) { ) {
} }
@@ -94,6 +97,24 @@ final readonly class CreateStudentHandler
studentNumber: $command->studentNumber, 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); $this->userRepository->save($user);
// Affecter à la classe // Affecter à la classe

View File

@@ -13,10 +13,12 @@ use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\SchoolClass\ClassId; use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\StudentResource; use App\Administration\Infrastructure\Api\Resource\StudentResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\StudentVoter; use App\Administration\Infrastructure\Security\StudentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use Override; use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
@@ -37,6 +39,7 @@ final readonly class CreateStudentProcessor implements ProcessorInterface
private MessageBusInterface $eventBus, private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker, private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver, 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.'); ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
try { try {
$adminUserId = null;
$securityUser = $this->security->getUser();
if ($securityUser instanceof SecurityUser) {
$adminUserId = $securityUser->userId();
}
$command = new CreateStudentCommand( $command = new CreateStudentCommand(
tenantId: $tenantId, tenantId: $tenantId,
schoolName: $tenantConfig->subdomain, schoolName: $tenantConfig->subdomain,
@@ -70,6 +80,8 @@ final readonly class CreateStudentProcessor implements ProcessorInterface
email: $data->email, email: $data->email,
dateNaissance: $data->dateNaissance, dateNaissance: $data->dateNaissance,
studentNumber: $data->studentNumber, studentNumber: $data->studentNumber,
parentalConsent: $data->parentalConsent,
consentRecordedBy: $adminUserId,
); );
$user = ($this->handler)($command); $user = ($this->handler)($command);

View File

@@ -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.')] #[Assert\Regex(pattern: '/^[A-Za-z0-9]{11}$/', message: 'L\'INE doit contenir exactement 11 caractères alphanumériques.')]
public ?string $studentNumber = null; public ?string $studentNumber = null;
public bool $parentalConsent = false;
public static function fromDto(StudentWithClassDto $dto): self public static function fromDto(StudentWithClassDto $dto): self
{ {
$resource = new self(); $resource = new self();

View File

@@ -189,6 +189,7 @@ final class CreateStudentHandlerTest extends TestCase
$this->classRepository, $this->classRepository,
$connection, $connection,
$this->clock, $this->clock,
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
); );
$command = $this->createCommand(); $command = $this->createCommand();
@@ -278,6 +279,7 @@ final class CreateStudentHandlerTest extends TestCase
$this->classRepository, $this->classRepository,
$this->connection, $this->connection,
$this->clock, $this->clock,
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
); );
} }

View File

@@ -40,6 +40,7 @@ export interface CreateStudentData {
email?: string | undefined; email?: string | undefined;
dateNaissance?: string | undefined; dateNaissance?: string | undefined;
studentNumber?: string | undefined; studentNumber?: string | undefined;
parentalConsent?: boolean | undefined;
} }
/** /**
@@ -93,7 +94,7 @@ export async function fetchClasses(): Promise<SchoolClass[]> {
*/ */
export async function createStudent(studentData: CreateStudentData): Promise<Student> { export async function createStudent(studentData: CreateStudentData): Promise<Student> {
const apiUrl = getApiBaseUrl(); const apiUrl = getApiBaseUrl();
const body: Record<string, string> = { const body: Record<string, string | boolean> = {
firstName: studentData.firstName, firstName: studentData.firstName,
lastName: studentData.lastName, lastName: studentData.lastName,
classId: studentData.classId classId: studentData.classId
@@ -101,6 +102,7 @@ export async function createStudent(studentData: CreateStudentData): Promise<Stu
if (studentData.email) body['email'] = studentData.email; if (studentData.email) body['email'] = studentData.email;
if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance; if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance;
if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber; if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber;
if (studentData.parentalConsent) body['parentalConsent'] = true;
const response = await authenticatedFetch(`${apiUrl}/students`, { const response = await authenticatedFetch(`${apiUrl}/students`, {
method: 'POST', method: 'POST',

View File

@@ -39,11 +39,24 @@
let newClassId = $state(''); let newClassId = $state('');
let newDateNaissance = $state(''); let newDateNaissance = $state('');
let newStudentNumber = $state(''); let newStudentNumber = $state('');
let newParentalConsent = $state(false);
let isSubmitting = $state(false); let isSubmitting = $state(false);
let duplicateWarning = $state<string | null>(null); let duplicateWarning = $state<string | null>(null);
let duplicateConfirmed = $state(false); let duplicateConfirmed = $state(false);
let createAnother = $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 // Change class modal state
let showChangeClassModal = $state(false); let showChangeClassModal = $state(false);
let changeClassTarget = $state<Student | null>(null); let changeClassTarget = $state<Student | null>(null);
@@ -176,6 +189,7 @@
newClassId = ''; newClassId = '';
newDateNaissance = ''; newDateNaissance = '';
newStudentNumber = ''; newStudentNumber = '';
newParentalConsent = false;
duplicateWarning = null; duplicateWarning = null;
duplicateConfirmed = false; duplicateConfirmed = false;
createAnother = false; createAnother = false;
@@ -224,6 +238,7 @@
async function handleCreateStudent() { async function handleCreateStudent() {
if (!newFirstName.trim() || !newLastName.trim() || !newClassId) return; if (!newFirstName.trim() || !newLastName.trim() || !newClassId) return;
if (needsParentalConsent() && !newParentalConsent) return;
if (await checkDuplicate()) return; if (await checkDuplicate()) return;
@@ -236,7 +251,8 @@
classId: newClassId, classId: newClassId,
email: newEmail.trim() ? newEmail.trim().toLowerCase() : undefined, email: newEmail.trim() ? newEmail.trim().toLowerCase() : undefined,
dateNaissance: newDateNaissance || 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 // Optimistic update: only add to list if matching current filters
@@ -375,7 +391,8 @@
newLastName.trim() !== '' && newLastName.trim() !== '' &&
newClassId !== '' && newClassId !== '' &&
!isSubmitting && !isSubmitting &&
(!newStudentNumber.trim() || isValidINE(newStudentNumber.trim())) (!newStudentNumber.trim() || isValidINE(newStudentNumber.trim())) &&
(!needsParentalConsent() || newParentalConsent)
); );
</script> </script>
@@ -609,6 +626,19 @@
</div> </div>
</div> </div>
{#if needsParentalConsent()}
<div class="consent-notice">
<label class="consent-label">
<input type="checkbox" bind:checked={newParentalConsent} />
<span>Consentement parental obtenu</span>
</label>
<span class="field-hint">
Obligatoire pour les élèves de moins de 15 ans (RGPD).
Confirmez que l'établissement a recueilli le consentement parental.
</span>
</div>
{/if}
{#if duplicateWarning} {#if duplicateWarning}
<div class="duplicate-warning"> <div class="duplicate-warning">
<p>{duplicateWarning}</p> <p>{duplicateWarning}</p>
@@ -1160,6 +1190,33 @@
color: #9ca3af; 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 { .field-error {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;