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
462 lines
18 KiB
TypeScript
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'}
|
|
}
|
|
}
|