From 40c7f40d127a518b5bd42798498f4a64e2563fc1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 30 Nov 2025 16:09:54 +0100 Subject: [PATCH] feat(backend): endpoint inscription parent + nettoyage code #9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit đŸ§č NETTOYAGE CODE ÉTUDIANT: - Suppression console.log/error (4 occurrences) - Suppression code commentĂ© inutile - Suppression champs obsolĂštes (mobile, telephone_fixe) - Correction password nullable dans Users entity ✹ NOUVELLES FONCTIONNALITÉS: - Ajout champs token_creation_mdp dans Users entity - CrĂ©ation RegisterParentDto (validation complĂšte) - Endpoint POST /auth/register/parent - MĂ©thode registerParent() avec transaction - Gestion Parent 1 + Parent 2 (co-parent optionnel) - GĂ©nĂ©ration tokens UUID pour crĂ©ation MDP - Lecture durĂ©e token depuis AppConfigService - CrĂ©ation automatique entitĂ©s Parents - Statut EN_ATTENTE par dĂ©faut - IntĂ©gration AppConfigModule dans AuthModule - AmĂ©lioration mĂ©thode login() (vĂ©rif statut + password null) 📋 WORKFLOW CDC CONFORME: - Inscription SANS mot de passe - Token envoyĂ© par email (TODO) - Validation gestionnaire requise - Support co-parent avec mĂȘme adresse RĂ©f: docs/20_WORKFLOW-CREATION-COMPTE.md Ticket: #9 (ou #16) --- backend/src/entities/users.entity.ts | 16 +- backend/src/routes/auth/auth.controller.ts | 13 +- backend/src/routes/auth/auth.module.ts | 6 + backend/src/routes/auth/auth.service.ts | 187 ++++++++++++++++-- .../routes/auth/dto/register-parent.dto.ts | 121 ++++++++++++ 5 files changed, 313 insertions(+), 30 deletions(-) create mode 100644 backend/src/routes/auth/dto/register-parent.dto.ts diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index f5e53b3..25db178 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -50,8 +50,8 @@ export class Users { @Column({ unique: true, name: 'email' }) email: string; - @Column({ name: 'password' }) - password: string; + @Column({ name: 'password', nullable: true }) + password?: string; @Column({ name: 'prenom', nullable: true }) prenom?: string; @@ -96,12 +96,6 @@ export class Users { @Column({ nullable: true, name: 'telephone' }) telephone?: string; - @Column({ name: 'mobile', nullable: true }) - mobile?: string; - - @Column({ name: 'telephone_fixe', nullable: true }) - telephone_fixe?: string; - @Column({ nullable: true, name: 'adresse' }) adresse?: string; @@ -117,6 +111,12 @@ export class Users { @Column({ default: false, name: 'changement_mdp_obligatoire' }) changement_mdp_obligatoire: boolean; + @Column({ nullable: true, name: 'token_creation_mdp', length: 255 }) + token_creation_mdp?: string; + + @Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' }) + token_creation_mdp_expire_le?: Date; + @Column({ nullable: true, name: 'ville' }) ville?: string; diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index c2cf0b4..3bbd23e 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; import { RegisterDto } from './dto/register.dto'; +import { RegisterParentDto } from './dto/register-parent.dto'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; import type { Request } from 'express'; @@ -30,11 +31,21 @@ export class AuthController { @Public() @Post('register') - @ApiOperation({ summary: 'Inscription' }) + @ApiOperation({ summary: 'Inscription (OBSOLÈTE - utiliser /register/parent)' }) + @ApiResponse({ status: 409, description: 'Email dĂ©jĂ  utilisĂ©' }) async register(@Body() dto: RegisterDto) { return this.authService.register(dto); } + @Public() + @Post('register/parent') + @ApiOperation({ summary: 'Inscription Parent (Ă©tape 1/6)' }) + @ApiResponse({ status: 201, description: 'Inscription rĂ©ussie' }) + @ApiResponse({ status: 409, description: 'Email dĂ©jĂ  utilisĂ©' }) + async registerParent(@Body() dto: RegisterParentDto) { + return this.authService.registerParent(dto); + } + @Public() @Post('refresh') @ApiBearerAuth('refresh_token') diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 946eebe..ad6e1b5 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -1,13 +1,19 @@ import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { UserModule } from '../user/user.module'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Users } from 'src/entities/users.entity'; +import { Parents } from 'src/entities/parents.entity'; +import { AppConfigModule } from 'src/modules/config'; @Module({ imports: [ + TypeOrmModule.forFeature([Users, Parents]), forwardRef(() => UserModule), + AppConfigModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 2157981..fad8a61 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -3,13 +3,19 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; import { RegisterDto } from './dto/register.dto'; +import { RegisterParentDto } from './dto/register-parent.dto'; import { ConfigService } from '@nestjs/config'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; +import { Parents } from 'src/entities/parents.entity'; import { LoginDto } from './dto/login.dto'; +import { AppConfigService } from 'src/modules/config/config.service'; @Injectable() export class AuthService { @@ -17,6 +23,11 @@ export class AuthService { private readonly usersService: UserService, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly appConfigService: AppConfigService, + @InjectRepository(Parents) + private readonly parentsRepo: Repository, + @InjectRepository(Users) + private readonly usersRepo: Repository, ) { } /** @@ -43,29 +54,37 @@ export class AuthService { * Connexion utilisateur */ async login(dto: LoginDto) { - try { - const user = await this.usersService.findByEmailOrNull(dto.email); + const user = await this.usersService.findByEmailOrNull(dto.email); - if (!user) { - throw new UnauthorizedException('Email invalide'); - } - console.log("Tentative login:", dto.email, JSON.stringify(dto.password)); - console.log("Utilisateur trouvĂ©:", user.email, user.password); - - const isMatch = await bcrypt.compare(dto.password, user.password); - console.log("RĂ©sultat bcrypt.compare:", isMatch); - if (!isMatch) { - throw new UnauthorizedException('Mot de passe invalide'); - } - // if (user.password !== dto.password) { - // throw new UnauthorizedException('Mot de passe invalide'); - // } - - return this.generateTokens(user.id, user.email, user.role); - } catch (error) { - console.error('Erreur de connexion :', error); + if (!user) { throw new UnauthorizedException('Identifiants invalides'); } + + // VĂ©rifier que le mot de passe existe (compte activĂ©) + if (!user.password) { + throw new UnauthorizedException( + 'Compte non activĂ©. Veuillez crĂ©er votre mot de passe via le lien reçu par email.', + ); + } + + // VĂ©rifier le mot de passe + const isMatch = await bcrypt.compare(dto.password, user.password); + if (!isMatch) { + throw new UnauthorizedException('Identifiants invalides'); + } + + // VĂ©rifier le statut du compte + if (user.statut === StatutUtilisateurType.EN_ATTENTE) { + throw new UnauthorizedException( + 'Votre compte est en attente de validation par un gestionnaire.', + ); + } + + if (user.statut === StatutUtilisateurType.SUSPENDU) { + throw new UnauthorizedException('Votre compte a Ă©tĂ© suspendu. Contactez un administrateur.'); + } + + return this.generateTokens(user.id, user.email, user.role); } /** @@ -89,7 +108,8 @@ export class AuthService { } /** - * Inscription utilisateur lambda (parent ou assistante maternelle) + * Inscription utilisateur OBSOLÈTE - Utiliser registerParent() ou registerAM() + * @deprecated */ async register(registerDto: RegisterDto) { const exists = await this.usersService.findByEmailOrNull(registerDto.email); @@ -129,6 +149,131 @@ export class AuthService { }; } + /** + * Inscription Parent (Ă©tape 1/6 du workflow CDC) + * SANS mot de passe - Token de crĂ©ation MDP gĂ©nĂ©rĂ© + */ + async registerParent(dto: RegisterParentDto) { + // 1. VĂ©rifier que l'email n'existe pas + const exists = await this.usersService.findByEmailOrNull(dto.email); + if (exists) { + throw new ConflictException('Un compte avec cet email existe dĂ©jĂ '); + } + + // 2. VĂ©rifier l'email du co-parent s'il existe + if (dto.co_parent_email) { + const coParentExists = await this.usersService.findByEmailOrNull(dto.co_parent_email); + if (coParentExists) { + throw new ConflictException('L\'email du co-parent est dĂ©jĂ  utilisĂ©'); + } + } + + // 3. RĂ©cupĂ©rer la durĂ©e d'expiration du token depuis la config + const tokenExpiryDays = await this.appConfigService.get( + 'password_reset_token_expiry_days', + 7, + ); + + // 4. GĂ©nĂ©rer les tokens de crĂ©ation de mot de passe + const tokenCreationMdp = crypto.randomUUID(); + const tokenExpiration = new Date(); + tokenExpiration.setDate(tokenExpiration.getDate() + tokenExpiryDays); + + // 5. Transaction : CrĂ©er Parent 1 + Parent 2 (si existe) + entitĂ©s parents + const result = await this.usersRepo.manager.transaction(async (manager) => { + // CrĂ©er Parent 1 + const parent1 = manager.create(Users, { + email: dto.email, + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.PARENT, + statut: StatutUtilisateurType.EN_ATTENTE, + telephone: dto.telephone, + adresse: dto.adresse, + code_postal: dto.code_postal, + ville: dto.ville, + profession: dto.profession, + situation_familiale: dto.situation_familiale, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: tokenExpiration, + }); + + if (dto.date_naissance) { + parent1.date_naissance = new Date(dto.date_naissance); + } + + const savedParent1 = await manager.save(Users, parent1); + + // CrĂ©er Parent 2 si renseignĂ© + let savedParent2: Users | null = null; + let tokenCoParent: string | null = null; + + if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) { + tokenCoParent = crypto.randomUUID(); + const tokenExpirationCoParent = new Date(); + tokenExpirationCoParent.setDate(tokenExpirationCoParent.getDate() + tokenExpiryDays); + + const parent2 = manager.create(Users, { + email: dto.co_parent_email, + prenom: dto.co_parent_prenom, + nom: dto.co_parent_nom, + role: RoleType.PARENT, + statut: StatutUtilisateurType.EN_ATTENTE, + telephone: dto.co_parent_telephone, + adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse, + code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal, + ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville, + token_creation_mdp: tokenCoParent, + token_creation_mdp_expire_le: tokenExpirationCoParent, + }); + + savedParent2 = await manager.save(Users, parent2); + } + + // CrĂ©er l'entitĂ© mĂ©tier Parents pour Parent 1 + const parentEntity = manager.create(Parents, { + user_id: savedParent1.id, + }); + parentEntity.user = savedParent1; + if (savedParent2) { + parentEntity.co_parent = savedParent2; + } + + await manager.save(Parents, parentEntity); + + // CrĂ©er l'entitĂ© mĂ©tier Parents pour Parent 2 (si existe) + if (savedParent2) { + const coParentEntity = manager.create(Parents, { + user_id: savedParent2.id, + }); + coParentEntity.user = savedParent2; + coParentEntity.co_parent = savedParent1; + + await manager.save(Parents, coParentEntity); + } + + return { + parent1: savedParent1, + parent2: savedParent2, + tokenCreationMdp, + tokenCoParent, + }; + }); + + // 6. TODO: Envoyer email avec lien de crĂ©ation de MDP + // await this.mailService.sendPasswordCreationEmail(result.parent1, result.tokenCreationMdp); + // if (result.parent2 && result.tokenCoParent) { + // await this.mailService.sendPasswordCreationEmail(result.parent2, result.tokenCoParent); + // } + + return { + message: 'Inscription rĂ©ussie. Un email de validation vous a Ă©tĂ© envoyĂ©.', + parent_id: result.parent1.id, + co_parent_id: result.parent2?.id, + statut: StatutUtilisateurType.EN_ATTENTE, + }; + } + async logout(userId: string) { // Pour le moment envoyer un message clair return { success: true, message: 'Deconnexion'} diff --git a/backend/src/routes/auth/dto/register-parent.dto.ts b/backend/src/routes/auth/dto/register-parent.dto.ts new file mode 100644 index 0000000..92b57a8 --- /dev/null +++ b/backend/src/routes/auth/dto/register-parent.dto.ts @@ -0,0 +1,121 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsDateString, + IsEnum, + MinLength, + MaxLength, + Matches, +} from 'class-validator'; +import { SituationFamilialeType } from 'src/entities/users.entity'; + +export class RegisterParentDto { + // === Informations obligatoires === + @ApiProperty({ example: 'claire.martin@ptits-pas.fr' }) + @IsEmail({}, { message: 'Email invalide' }) + @IsNotEmpty({ message: 'L\'email est requis' }) + email: string; + + @ApiProperty({ example: 'Claire' }) + @IsString() + @IsNotEmpty({ message: 'Le prĂ©nom est requis' }) + @MinLength(2, { message: 'Le prĂ©nom doit contenir au moins 2 caractĂšres' }) + @MaxLength(100, { message: 'Le prĂ©nom ne peut pas dĂ©passer 100 caractĂšres' }) + prenom: string; + + @ApiProperty({ example: 'MARTIN' }) + @IsString() + @IsNotEmpty({ message: 'Le nom est requis' }) + @MinLength(2, { message: 'Le nom doit contenir au moins 2 caractĂšres' }) + @MaxLength(100, { message: 'Le nom ne peut pas dĂ©passer 100 caractĂšres' }) + nom: string; + + @ApiProperty({ example: '0689567890' }) + @IsString() + @IsNotEmpty({ message: 'Le tĂ©lĂ©phone est requis' }) + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numĂ©ro de tĂ©lĂ©phone doit ĂȘtre valide (ex: 0689567890 ou +33689567890)', + }) + telephone: string; + + // === Informations optionnelles === + @ApiProperty({ example: '5 Avenue du GĂ©nĂ©ral de Gaulle', required: false }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiProperty({ example: '95870', required: false }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiProperty({ example: 'Bezons', required: false }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiProperty({ example: 'InfirmiĂšre', required: false }) + @IsOptional() + @IsString() + @MaxLength(150) + profession?: string; + + @ApiProperty({ enum: SituationFamilialeType, example: SituationFamilialeType.MARIE, required: false }) + @IsOptional() + @IsEnum(SituationFamilialeType) + situation_familiale?: SituationFamilialeType; + + @ApiProperty({ example: '1990-04-03', required: false }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + // === Informations co-parent (optionnel) === + @ApiProperty({ example: 'thomas.martin@ptits-pas.fr', required: false }) + @IsOptional() + @IsEmail({}, { message: 'Email du co-parent invalide' }) + co_parent_email?: string; + + @ApiProperty({ example: 'Thomas', required: false }) + @IsOptional() + @IsString() + co_parent_prenom?: string; + + @ApiProperty({ example: 'MARTIN', required: false }) + @IsOptional() + @IsString() + co_parent_nom?: string; + + @ApiProperty({ example: '0612345678', required: false }) + @IsOptional() + @IsString() + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numĂ©ro de tĂ©lĂ©phone du co-parent doit ĂȘtre valide', + }) + co_parent_telephone?: string; + + @ApiProperty({ example: 'true', description: 'Le co-parent habite Ă  la mĂȘme adresse', required: false }) + @IsOptional() + co_parent_meme_adresse?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_code_postal?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_ville?: string; +} +