Merge pull request #161 from ineszang/dev

Remontée dev vers main : première connexion sans ressaisie du mot de passe provisoire
This commit is contained in:
Johan LEROY
2026-09-23 14:51:25 +02:00
committed by GitHub
6 changed files with 180 additions and 50 deletions
@@ -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);
@@ -19,6 +19,9 @@ export class AuthService {
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal<string | null>(null);
private principalSignal = signal<Principal | null>(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<TokenResponse> {
return this.http
.post<TokenResponse>(`${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
@@ -7,14 +7,18 @@
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
</p>
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input
id="current_password"
class="form-input"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<input hidden type="email" autocomplete="username" [value]="email" readonly />
@if (asksCurrentPassword()) {
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input
id="current_password"
class="form-input"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
}
<label class="form-label" for="new_password">Nouveau mot de passe</label>
<input
@@ -24,7 +28,7 @@
formControlName="new_password"
autocomplete="new-password"
/>
<span class="form-hint">{{ passwordHint }}</span>
<app-password-requirements [password]="newPassword()" />
@if (errorMessage()) {
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
@@ -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<typeof vi.fn> };
let authMock: {
changePassword: ReturnType<typeof vi.fn>;
takeProvisionalPassword: ReturnType<typeof vi.fn>;
principal: ReturnType<typeof signal>;
};
let routerMock: { navigate: ReturnType<typeof vi.fn> };
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,
});
});
});
});
@@ -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<string | null>(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.';
}
}
+1 -1
View File
@@ -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();