Application mono-utilisateur : inscription unique, connexion par JWT (12 h), protection de toutes les routes de données, réinitialisation de mot de passe par email avec repli de journalisation, onboarding réorganisé en deux étapes (inscription → connexion → profil + véhicule). Backend (SchotzIadKmTracker.Api) : - Entités Compte (singleton) et ResetPasswordToken + migration AjoutAuthentification. - Hachage PasswordHasher, JwtService (HS256, durée configurable), IEmailService (MailKit + repli LoggingEmailService selon Smtp:Host). - AuthService (register 409 si compte existant, login 401 générique, forgot-password non-divulgation, reset à usage unique + expiration), AuthController (5 endpoints publics), DTOs + validateurs FluentValidation. - Sécurité pipeline : JwtBearer + filtre d'autorisation global, UseAuthentication avant UseAuthorization ; /health et /api/auth/* publics. Frontend (schotziad-km-tracker, zoneless + signals) : - AuthService (localStorage), authInterceptor (Bearer + purge/redirection 401), authGuard, écrans Login/Register/ForgotPassword/ResetPassword, routes publiques et protection [authGuard, profilConfigureGuard]. Configuration : .env.example et docker-compose.yml (JWT__SecretKey, durées, Smtp__*). Tests : 54 tests backend (propriétés FsCheck P1–P14, exemples, intégration WebApplicationFactory) + tests frontend (AuthService, authGuard, authInterceptor, ResetPasswordComponent). Suite complète au vert. Spec : .kiro/specs/authentification (requirements, design, tasks).
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
|
import { Component, inject, signal } from '@angular/core';
|
|
import {
|
|
AbstractControl,
|
|
FormBuilder,
|
|
FormGroup,
|
|
ReactiveFormsModule,
|
|
ValidationErrors,
|
|
Validators
|
|
} from '@angular/forms';
|
|
import { HttpErrorResponse } from '@angular/common/http';
|
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
|
|
import { AuthService } from '../../../core/services/auth.service';
|
|
import { NotificationService } from '../../../core/services/notification.service';
|
|
import { ResetPasswordDto } from '../../../core/models';
|
|
|
|
/**
|
|
* Validateur de groupe : vérifie l'égalité entre `nouveauMotDePasse` et
|
|
* `confirmation`. Pose l'erreur `motsDePasseDifferents` sur le groupe lorsque
|
|
* les deux valeurs diffèrent.
|
|
*/
|
|
function egaliteMotsDePasse(control: AbstractControl): ValidationErrors | null {
|
|
const nouveau = control.get('nouveauMotDePasse')?.value ?? '';
|
|
const confirmation = control.get('confirmation')?.value ?? '';
|
|
return nouveau === confirmation ? null : { motsDePasseDifferents: true };
|
|
}
|
|
|
|
/**
|
|
* Écran de réinitialisation du mot de passe (`/reset-password`).
|
|
*
|
|
* Lit le paramètre de requête `token` depuis l'URL (via `ActivatedRoute`) et le
|
|
* conserve dans un signal. Si le token est absent, un message invite à
|
|
* redemander un lien. Le formulaire saisit le nouveau mot de passe (>= 8
|
|
* caractères) et sa confirmation (validateur d'égalité). Sur succès, une
|
|
* notification est émise puis le composant navigue vers `/login` (Exigence 7.6).
|
|
*
|
|
* Contrainte zoneless : tout état async est exposé via des signals
|
|
* (`soumissionEnCours`, `erreur`, `token`).
|
|
*
|
|
* _Requirements: 7.6_
|
|
*/
|
|
@Component({
|
|
selector: 'app-reset-password',
|
|
standalone: true,
|
|
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
|
templateUrl: './reset-password.component.html',
|
|
styleUrl: './reset-password.component.scss'
|
|
})
|
|
export class ResetPasswordComponent {
|
|
private readonly fb = inject(FormBuilder);
|
|
private readonly authService = inject(AuthService);
|
|
private readonly router = inject(Router);
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly notification = inject(NotificationService);
|
|
|
|
/** Token de réinitialisation lu depuis l'URL (`?token=...`), ou null si absent. */
|
|
readonly token = signal<string | null>(
|
|
this.route.snapshot.queryParamMap.get('token')
|
|
);
|
|
|
|
/** Vrai pendant l'appel de réinitialisation (désactive le bouton). */
|
|
readonly soumissionEnCours = signal(false);
|
|
|
|
/** Message d'erreur courant, ou null si aucune erreur. */
|
|
readonly erreur = signal<string | null>(null);
|
|
|
|
readonly form: FormGroup = this.fb.group(
|
|
{
|
|
nouveauMotDePasse: ['', [Validators.required, Validators.minLength(8)]],
|
|
confirmation: ['', [Validators.required]]
|
|
},
|
|
{ validators: egaliteMotsDePasse }
|
|
);
|
|
|
|
/** Tente la réinitialisation avec le nouveau mot de passe saisi. */
|
|
reinitialiser(): void {
|
|
const token = this.token();
|
|
if (this.form.invalid || token === null || this.soumissionEnCours()) {
|
|
this.form.markAllAsTouched();
|
|
return;
|
|
}
|
|
|
|
const valeur = this.form.getRawValue();
|
|
const dto: ResetPasswordDto = {
|
|
token,
|
|
nouveauMotDePasse: valeur.nouveauMotDePasse ?? ''
|
|
};
|
|
|
|
this.erreur.set(null);
|
|
this.soumissionEnCours.set(true);
|
|
this.authService.resetPassword(dto).subscribe({
|
|
next: () => {
|
|
this.soumissionEnCours.set(false);
|
|
this.notification.success('Votre mot de passe a été réinitialisé.');
|
|
this.router.navigate(['/login']);
|
|
},
|
|
error: (err: HttpErrorResponse) => {
|
|
this.soumissionEnCours.set(false);
|
|
let message: string;
|
|
if (err.status === 400) {
|
|
// ProblemDetails : le backend fournit un message précis
|
|
// (lien expiré / lien invalide) via `detail` le cas échéant.
|
|
message =
|
|
err.error?.detail ??
|
|
'Le lien de réinitialisation est invalide ou a expiré.';
|
|
} else {
|
|
message = 'La réinitialisation a échoué. Veuillez réessayer.';
|
|
}
|
|
this.erreur.set(message);
|
|
this.notification.error(message);
|
|
}
|
|
});
|
|
}
|
|
}
|