Réorganisation complète de la navigation suite au retour d'usage, plus changement de mot de passe et affichage/masquage des mots de passe saisis. Backend : - Nouvel endpoint POST /api/auth/change-password (ChangePasswordDto, MotDePasseActuelInvalideException, validateur, AuthService.ChangePasswordAsync). Exige l'ancien mot de passe, distinct de la réinitialisation par e-mail. - Endpoint placé dans SessionController (protégé par l'autorisation globale) et NON dans AuthController : AuthController porte [AllowAnonymous] au niveau classe, ce qui rend inopérant un [Authorize] posé sur une seule action (bug de sécurité détecté par test manuel — l'endpoint était accessible sans jeton — puis corrigé par ce déplacement). Frontend : - Page « Compte utilisateur » (ProfilComponent renommé) : section Maintenance extraite vers MaintenanceComponent (route /maintenance) ; nouvelle section de changement de mot de passe (ancien + nouveau + confirmation). - ParametrageComponent (route /parametrage) : menu latéral Compte / Véhicules / Barème + contenu en routes enfants, réutilisant les composants existants. Compte et Véhicules en authGuard seul ; Barème garde profilConfigureGuard. - Barre de navigation : Véhicules et Barème retirés (rejoignent Paramétrage) ; logo passé en format horizontal (icône + texte). - Avatar utilisateur (initiales) à droite de la barre : sous-menu au survol (identité, Paramétrage, Maintenance, Se déconnecter). Menu « Specs » converti en dropdown survolable (Exigences / Conception / Tâches). - Dropdowns : panneau collé au bouton (plus d'espace mort) pour que le survol reste continu jusqu'au panneau — un espace créait une fermeture prématurée. - Barre masquée sur les pages publiques (login/register/forgot/reset-password) même avec un jeton résiduel expiré/invalide (détection via Router events, pas seulement la présence du jeton). - PasswordInputComponent (shared) : bouton œil afficher/masquer, ControlValueAccessor, style aligné sur les champs texte classiques. Appliqué aux 8 champs mot de passe de l'application (login, register, reset-password, compte utilisateur). Specs : requirements.md (exigences 20, 21 + glossaire) et design.md (endpoints, DTO, composants) mis à jour. 54 tests backend au vert.
117 lines
4.2 KiB
TypeScript
117 lines
4.2 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 { PasswordInputComponent } from '../../../shared/components/password-input/password-input.component';
|
|
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, PasswordInputComponent],
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
}
|