From c2f360c5919355fb6201899b351a5c21c2a378f5 Mon Sep 17 00:00:00 2001
From: Johan LEROY
Date: Wed, 23 Sep 2026 14:42:37 +0200
Subject: [PATCH] =?UTF-8?q?fix(auth):=20ne=20plus=20redemander=20le=20mot?=
=?UTF-8?q?=20de=20passe=20provisoire=20=C3=A0=20la=20premi=C3=A8re=20conn?=
=?UTF-8?q?exion?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
L'écran de changement imposé redemandait le mot de passe provisoire qui venait
d'être vérifié, sans champ identifiant. Un gestionnaire de mots de passe y
collait un ancien mot de passe du site : /auth/password répondait 401
« Identifiants invalides », et le message unique accusait aussi la politique
de mot de passe. Constaté en rec et en dev sur les comptes nominatifs.
- AuthService garde en mémoire le mot de passe d'une connexion qui impose le
changement, rendu une seule fois par takeProvisionalPassword() et effacé
avec la session.
- Le champ « Mot de passe actuel » ne s'affiche que si ce mot de passe manque
(page rechargée) ou vient d'être refusé.
- Champ identifiant masqué pour les gestionnaires de mots de passe.
- Messages distincts pour 401, 422 et le reste, liste des critères en direct.
---
.../app/core/services/auth.service.spec.ts | 25 ++++
.../src/app/core/services/auth.service.ts | 19 ++-
.../auth/change-password/change-password.html | 22 +--
.../change-password/change-password.spec.ts | 126 +++++++++++++-----
.../auth/change-password/change-password.ts | 36 +++--
tests/e2e/specs/premiere-connexion.spec.ts | 2 +-
6 files changed, 180 insertions(+), 50 deletions(-)
diff --git a/apps/frontend/src/app/core/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts
index c51c8eb..aa3244c 100644
--- a/apps/frontend/src/app/core/services/auth.service.spec.ts
+++ b/apps/frontend/src/app/core/services/auth.service.spec.ts
@@ -43,6 +43,31 @@ describe('AuthService', () => {
expect(service.isAuthenticated()).toBe(true);
});
+ it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => {
+ service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
+ httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
+ ...tokenResponse,
+ principal: { ...tokenResponse.principal, must_change_password: true },
+ });
+
+ expect(service.takeProvisionalPassword()).toBe('Provisoire');
+ expect(service.takeProvisionalPassword()).toBeNull();
+ });
+
+ it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => {
+ service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe();
+ httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
+ expect(service.takeProvisionalPassword()).toBeNull();
+
+ service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
+ httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
+ ...tokenResponse,
+ principal: { ...tokenResponse.principal, must_change_password: true },
+ });
+ service.clearSession();
+ expect(service.takeProvisionalPassword()).toBeNull();
+ });
+
it('efface la session au logout', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts
index c2d3e9c..5441349 100644
--- a/apps/frontend/src/app/core/services/auth.service.ts
+++ b/apps/frontend/src/app/core/services/auth.service.ts
@@ -19,6 +19,9 @@ export class AuthService {
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal(null);
private principalSignal = signal(null);
+ // Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire
+ // de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401.
+ private provisionalPassword: string | null = null;
readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
@@ -37,12 +40,26 @@ export class AuthService {
clearSession(): void {
this.accessTokenSignal.set(null);
this.principalSignal.set(null);
+ this.provisionalPassword = null;
}
login(credentials: LoginRequest): Observable {
return this.http
.post(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
- .pipe(tap((response) => this.setSession(response)));
+ .pipe(
+ tap((response) => {
+ this.setSession(response);
+ this.provisionalPassword = response.principal.must_change_password
+ ? credentials.password
+ : null;
+ })
+ );
+ }
+
+ takeProvisionalPassword(): string | null {
+ const password = this.provisionalPassword;
+ this.provisionalPassword = null;
+ return password;
}
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html
index 6c3de32..2777d4e 100644
--- a/apps/frontend/src/app/features/auth/change-password/change-password.html
+++ b/apps/frontend/src/app/features/auth/change-password/change-password.html
@@ -7,14 +7,18 @@
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
-
-
+
+
+ @if (asksCurrentPassword()) {
+
+
+ }
- {{ passwordHint }}
+
@if (errorMessage()) {
{{ errorMessage() }}
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts
index 126e892..3b340c0 100644
--- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts
+++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts
@@ -1,17 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
+import { HttpErrorResponse } from '@angular/common/http';
+import { signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ChangePassword } from './change-password';
import { AuthService } from '../../../core/services/auth.service';
+const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
+
describe('ChangePassword', () => {
- let authMock: { changePassword: ReturnType };
+ let authMock: {
+ changePassword: ReturnType;
+ takeProvisionalPassword: ReturnType;
+ principal: ReturnType;
+ };
let routerMock: { navigate: ReturnType };
beforeEach(async () => {
- authMock = { changePassword: vi.fn() };
+ authMock = {
+ changePassword: vi.fn(),
+ takeProvisionalPassword: vi.fn().mockReturnValue(null),
+ principal: signal({ email: 'johan@enervision.fr' }),
+ };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
}).compileComponents();
});
+ function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null {
+ return fixture.nativeElement.querySelector('#current_password');
+ }
+
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
- component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
+ component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -52,46 +68,94 @@ describe('ChangePassword', () => {
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
- it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
- const fixture = TestBed.createComponent(ChangePassword);
- const component = fixture.componentInstance;
- component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
+ it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => {
+ const fixture = TestBed.createComponent(ChangePassword);
+ fixture.detectChanges();
- authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
+ expect(champActuel(fixture)).not.toBeNull();
+ });
- component.onSubmit();
- fixture.detectChanges(); // rend le bloc @if (errorMessage())
+ it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
+ authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
+ authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
+ const fixture = TestBed.createComponent(ChangePassword);
+ const component = fixture.componentInstance;
+ fixture.detectChanges();
- expect(component.errorMessage()).toContain('incorrect');
- const errorEl = fixture.nativeElement.querySelector('.ev-alert');
- expect(errorEl?.textContent).toContain('incorrect');
+ expect(champActuel(fixture)).toBeNull();
+ component.form.controls.new_password.setValue(NOUVEAU);
+ component.onSubmit();
+
+ expect(authMock.changePassword).toHaveBeenCalledWith({
+ current_password: 'Provisoire-24-caracteres',
+ new_password: NOUVEAU,
+ });
+ });
+
+ it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => {
+ const fixture = TestBed.createComponent(ChangePassword);
+ fixture.detectChanges();
+
+ const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]');
+ expect(identifiant.value).toBe('johan@enervision.fr');
+ });
+
+ it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => {
+ authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime');
+ authMock.changePassword.mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 401 })),
+ );
+ const fixture = TestBed.createComponent(ChangePassword);
+ const component = fixture.componentInstance;
+ component.form.controls.new_password.setValue(NOUVEAU);
+
+ component.onSubmit();
+ fixture.detectChanges();
+
+ expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
+ expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
+ expect(champActuel(fixture)).not.toBeNull();
+ expect(component.form.controls.current_password.value).toBe('');
+ });
+
+ it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => {
+ authMock.changePassword.mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 422 })),
+ );
+ const fixture = TestBed.createComponent(ChangePassword);
+ const component = fixture.componentInstance;
+ component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
+
+ component.onSubmit();
+
+ expect(component.errorMessage()).toContain('Nouveau mot de passe refusé');
+ expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
- const fixture = TestBed.createComponent(ChangePassword);
- fixture.detectChanges();
+ const fixture = TestBed.createComponent(ChangePassword);
+ fixture.detectChanges();
- const button = fixture.nativeElement.querySelector('button[type="submit"]');
- expect(button.disabled).toBe(true);
- expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
+ const button = fixture.nativeElement.querySelector('button[type="submit"]');
+ expect(button.disabled).toBe(true);
+ expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
- const fixture = TestBed.createComponent(ChangePassword);
- const component = fixture.componentInstance;
- component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
- fixture.detectChanges();
+ const fixture = TestBed.createComponent(ChangePassword);
+ const component = fixture.componentInstance;
+ component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
+ fixture.detectChanges();
- authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
+ authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
- const form = fixture.nativeElement.querySelector('form');
- form.dispatchEvent(new Event('submit'));
- fixture.detectChanges();
+ const form = fixture.nativeElement.querySelector('form');
+ form.dispatchEvent(new Event('submit'));
+ fixture.detectChanges();
- expect(authMock.changePassword).toHaveBeenCalledWith({
- current_password: 'ancien-mot-de-passe',
- new_password: 'Un-nouveau-mot-de-passe1!',
+ expect(authMock.changePassword).toHaveBeenCalledWith({
+ current_password: 'ancien-mot-de-passe',
+ new_password: NOUVEAU,
+ });
});
});
-
-});
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts
index 06be74d..662b1e9 100644
--- a/apps/frontend/src/app/features/auth/change-password/change-password.ts
+++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts
@@ -1,17 +1,20 @@
import { Component, inject, signal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
+import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
+import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({
selector: 'app-change-password',
standalone: true,
- imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
+ imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
@@ -20,30 +23,47 @@ export class ChangePassword {
private auth = inject(AuthService);
private router = inject(Router);
+ private provisionalPassword = this.auth.takeProvisionalPassword();
+
errorMessage = signal(null);
isLoading = signal(false);
- passwordHint = PASSWORD_HINT;
+ asksCurrentPassword = signal(this.provisionalPassword === null);
+ email = this.auth.principal()?.email ?? '';
form = this.fb.nonNullable.group({
- current_password: ['', Validators.required],
+ current_password: [this.provisionalPassword ?? '', Validators.required],
new_password: ['', passwordValidators],
});
+ newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
+
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.changePassword(this.form.getRawValue()).subscribe({
- next: (response) => {
+ next: () => {
this.router.navigate(['/dashboard']);
},
- error: () => {
+ error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
- this.errorMessage.set(
- `Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
- );
+ this.errorMessage.set(this.explique(error));
+ if (error.status === 401) {
+ this.form.controls.current_password.reset('');
+ this.asksCurrentPassword.set(true);
+ }
},
});
}
+
+ private explique(error: HttpErrorResponse): string {
+ if (error.status === 401) {
+ return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.';
+ }
+ if (error.status === 422) {
+ return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`;
+ }
+ return 'Le changement de mot de passe a échoué, réessayez dans un instant.';
+ }
}
diff --git a/tests/e2e/specs/premiere-connexion.spec.ts b/tests/e2e/specs/premiere-connexion.spec.ts
index 3f34783..5e894bb 100644
--- a/tests/e2e/specs/premiere-connexion.spec.ts
+++ b/tests/e2e/specs/premiere-connexion.spec.ts
@@ -12,7 +12,7 @@ test('impose le changement du mot de passe temporaire avant le tableau de bord',
await expect(page).toHaveURL(/\/change-password$/);
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
- await page.getByLabel('Mot de passe actuel').fill(compte.password);
+ await expect(page.getByLabel('Mot de passe actuel')).toHaveCount(0);
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
await page.getByRole('button', { name: 'Valider' }).click();