petitspas/backend/src/routes/auth/auth.service.ts
Julien Martin 9ae6533b4d feat(#36): Inscription Parent - Étape 1 (Parent 1)
Backend:
- Retrait des champs non-CDC: profession, situation_familiale, date_naissance
- Nettoyage des DTOs RegisterParentCompletDto et RegisterParentDto
- Mise à jour de la logique dans auth.service.ts (inscrireParentComplet et legacy)

Frontend Step1:
- Suppression des champs mot de passe et confirmation
- Correction de l'indicateur d'étape: 1/5 → 1/6
- Améliorations visuelles:
  * Taille des labels: 18 → 22px
  * Taille de police des champs: 18 → 20px
  * Espacement entre champs: 20 → 32px
  * Meilleure répartition verticale avec spaceEvenly

Note: Le champ password est conservé dans le modèle ParentData pour compatibilité avec Step2
2025-12-01 23:19:58 +01:00

462 lines
18 KiB
TypeScript

import {
ConflictException,
Injectable,
UnauthorizedException,
BadRequestException,
} 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 * as fs from 'fs/promises';
import * as path from 'path';
import { RegisterDto } from './dto/register.dto';
import { RegisterParentDto } from './dto/register-parent.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { ConfigService } from '@nestjs/config';
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { LoginDto } from './dto/login.dto';
import { AppConfigService } from 'src/modules/config/config.service';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UserService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly appConfigService: AppConfigService,
@InjectRepository(Parents)
private readonly parentsRepo: Repository<Parents>,
@InjectRepository(Users)
private readonly usersRepo: Repository<Users>,
@InjectRepository(Children)
private readonly childrenRepo: Repository<Children>,
) { }
/**
* Génère un access_token et un refresh_token
*/
async generateTokens(userId: string, email: string, role: RoleType) {
const accessSecret = this.configService.get<string>('jwt.accessSecret');
const accessExpiresIn = this.configService.get<string>('jwt.accessExpiresIn');
const refreshSecret = this.configService.get<string>('jwt.refreshSecret');
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn');
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync({ sub: userId, email, role }, { secret: accessSecret, expiresIn: accessExpiresIn }),
this.jwtService.signAsync({ sub: userId }, { secret: refreshSecret, expiresIn: refreshExpiresIn }),
]);
return {
access_token: accessToken,
refresh_token: refreshToken,
};
}
/**
* Connexion utilisateur
*/
async login(dto: LoginDto) {
const user = await this.usersService.findByEmailOrNull(dto.email);
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);
}
/**
* Rafraîchir les tokens
*/
async refreshTokens(refreshToken: string) {
try {
const payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.configService.get<string>('jwt.refreshSecret'),
});
const user = await this.usersService.findOne(payload.sub);
if (!user) {
throw new UnauthorizedException('Utilisateur introuvable');
}
return this.generateTokens(user.id, user.email, user.role);
} catch {
throw new UnauthorizedException('Refresh token invalide');
}
}
/**
* Inscription utilisateur OBSOLÈTE - Utiliser registerParent() ou registerAM()
* @deprecated
*/
async register(registerDto: RegisterDto) {
const exists = await this.usersService.findByEmailOrNull(registerDto.email);
if (exists) {
throw new ConflictException('Email déjà utilisé');
}
const allowedRoles = new Set<RoleType>([RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE]);
if (!allowedRoles.has(registerDto.role)) {
registerDto.role = RoleType.PARENT;
}
registerDto.statut = StatutUtilisateurType.EN_ATTENTE;
if (!registerDto.consentement_photo) {
registerDto.date_consentement_photo = null;
} else if (registerDto.date_consentement_photo) {
const date = new Date(registerDto.date_consentement_photo);
if (isNaN(date.getTime())) {
registerDto.date_consentement_photo = null;
}
}
const user = await this.usersService.createUser(registerDto);
const tokens = await this.generateTokens(user.id, user.email, user.role);
return {
...tokens,
user: {
id: user.id,
email: user.email,
role: user.role,
prenom: user.prenom,
nom: user.nom,
statut: user.statut,
},
};
}
/**
* 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<number>(
'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,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: tokenExpiration,
});
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,
};
}
/**
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
*/
async inscrireParentComplet(dto: RegisterParentCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
}
if (!dto.enfants || dto.enfants.length === 0) {
throw new BadRequestException('Au moins un enfant est requis');
}
const existe = await this.usersService.findByEmailOrNull(dto.email);
if (existe) {
throw new ConflictException('Un compte avec cet email existe déjà');
}
if (dto.co_parent_email) {
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
if (coParentExiste) {
throw new ConflictException('L\'email du co-parent est déjà utilisé');
}
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
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,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
});
const parent1Enregistre = await manager.save(Users, parent1);
let parent2Enregistre: 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 dateExpirationCoParent = new Date();
dateExpirationCoParent.setDate(dateExpirationCoParent.getDate() + joursExpirationToken);
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: dateExpirationCoParent,
});
parent2Enregistre = await manager.save(Users, parent2);
}
const entiteParent = manager.create(Parents, {
user_id: parent1Enregistre.id,
});
entiteParent.user = parent1Enregistre;
if (parent2Enregistre) {
entiteParent.co_parent = parent2Enregistre;
}
await manager.save(Parents, entiteParent);
if (parent2Enregistre) {
const entiteCoParent = manager.create(Parents, {
user_id: parent2Enregistre.id,
});
entiteCoParent.user = parent2Enregistre;
entiteCoParent.co_parent = parent1Enregistre;
await manager.save(Parents, entiteCoParent);
}
const enfantsEnregistres: Children[] = [];
for (const enfantDto of dto.enfants) {
let urlPhoto: string | null = null;
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
urlPhoto = await this.sauvegarderPhotoDepuisBase64(
enfantDto.photo_base64,
enfantDto.photo_filename,
);
}
const enfant = new Children();
enfant.first_name = enfantDto.prenom;
enfant.last_name = enfantDto.nom || dto.nom;
enfant.gender = enfantDto.genre;
enfant.birth_date = enfantDto.date_naissance ? new Date(enfantDto.date_naissance) : undefined;
enfant.due_date = enfantDto.date_previsionnelle_naissance
? new Date(enfantDto.date_previsionnelle_naissance)
: undefined;
enfant.photo_url = urlPhoto || undefined;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE;
enfant.consent_photo = false;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant);
enfantsEnregistres.push(enfantEnregistre);
const lienParentEnfant1 = manager.create(ParentsChildren, {
parentId: parent1Enregistre.id,
enfantId: enfantEnregistre.id,
});
await manager.save(ParentsChildren, lienParentEnfant1);
if (parent2Enregistre) {
const lienParentEnfant2 = manager.create(ParentsChildren, {
parentId: parent2Enregistre.id,
enfantId: enfantEnregistre.id,
});
await manager.save(ParentsChildren, lienParentEnfant2);
}
}
return {
parent1: parent1Enregistre,
parent2: parent2Enregistre,
enfants: enfantsEnregistres,
tokenCreationMdp,
tokenCoParent,
};
});
return {
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
parent_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id,
enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE,
};
}
/**
* Sauvegarde une photo depuis base64 vers le système de fichiers
*/
private async sauvegarderPhotoDepuisBase64(donneesBase64: string, nomFichier: string): Promise<string> {
const correspondances = donneesBase64.match(/^data:image\/(\w+);base64,(.+)$/);
if (!correspondances) {
throw new BadRequestException('Format de photo invalide (doit être base64)');
}
const extension = correspondances[1];
const tamponImage = Buffer.from(correspondances[2], 'base64');
const dossierUpload = '/app/uploads/photos';
await fs.mkdir(dossierUpload, { recursive: true });
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
const cheminFichier = path.join(dossierUpload, nomFichierUnique);
await fs.writeFile(cheminFichier, tamponImage);
return `/uploads/photos/${nomFichierUnique}`;
}
async logout(userId: string) {
return { success: true, message: 'Deconnexion'}
}
}