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:
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SchoolClass[]> {
|
||||
*/
|
||||
export async function createStudent(studentData: CreateStudentData): Promise<Student> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const body: Record<string, string> = {
|
||||
const body: Record<string, string | boolean> = {
|
||||
firstName: studentData.firstName,
|
||||
lastName: studentData.lastName,
|
||||
classId: studentData.classId
|
||||
@@ -101,6 +102,7 @@ export async function createStudent(studentData: CreateStudentData): Promise<Stu
|
||||
if (studentData.email) body['email'] = studentData.email;
|
||||
if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance;
|
||||
if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber;
|
||||
if (studentData.parentalConsent) body['parentalConsent'] = true;
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/students`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -39,11 +39,24 @@
|
||||
let newClassId = $state('');
|
||||
let newDateNaissance = $state('');
|
||||
let newStudentNumber = $state('');
|
||||
let newParentalConsent = $state(false);
|
||||
let isSubmitting = $state(false);
|
||||
let duplicateWarning = $state<string | null>(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<Student | null>(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)
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -609,6 +626,19 @@
|
||||
</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}
|
||||
<div class="duplicate-warning">
|
||||
<p>{duplicateWarning}</p>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user