diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index d39298d..d100e5e 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,28 +1,43 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; import { Login } from './login'; import { AuthService } from '../../../core/services/auth.service'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; + +function configure(queryParams: Record = {}) { + const authMock = { login: vi.fn() }; + const routerMock = { navigate: vi.fn() }; + + return { + authMock, + routerMock, + testBed: TestBed.configureTestingModule({ + imports: [Login, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } }, + }, + ], + }), + }; +} describe('Login', () => { let authMock: { login: ReturnType }; let routerMock: { navigate: ReturnType }; beforeEach(async () => { - authMock = { login: vi.fn() }; - routerMock = { navigate: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [Login, ReactiveFormsModule], - providers: [ - { provide: AuthService, useValue: authMock }, - { provide: Router, useValue: routerMock }, - { provide: ActivatedRoute, useValue: {} }, - ], - }).compileComponents(); + const attirail = configure(); + authMock = attirail.authMock; + routerMock = attirail.routerMock; + await attirail.testBed.compileComponents(); }); it('ne soumet pas si le formulaire est invalide', () => { @@ -85,6 +100,14 @@ describe('Login', () => { expect(errorEl?.textContent).toContain('30s'); }); + it('affiche le message standard quand on arrive avec ?motif=lien-expire', async () => { + const attirail = configure({ motif: MOTIF_LIEN_RESET_INVALIDE }); + await attirail.testBed.compileComponents(); + const fixture = TestBed.createComponent(Login); + + expect(fixture.componentInstance.errorMessage()).toContain('expiré'); + }); + it('désactive le bouton tant que le formulaire est invalide', () => { const fixture = TestBed.createComponent(Login); fixture.detectChanges(); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 871e7cc..f9bd085 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,8 +1,9 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; +import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; @Component({ selector: 'app-login', @@ -15,8 +16,13 @@ export class Login { private fb = inject(FormBuilder); private auth = inject(AuthService); private router = inject(Router); + private route = inject(ActivatedRoute); - errorMessage = signal(null); + errorMessage = signal( + this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE + ? MESSAGE_LIEN_RESET_INVALIDE + : null, + ); retryAfterSeconds = signal(null); isLoading = signal(false); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html index eed77a8..57d202e 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.html +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -2,9 +2,7 @@

Nouveau mot de passe

- @if (!hasToken) { -

Ce lien est incomplet. Redemandez un lien de réinitialisation.

- } @else { + @if (hasToken) {

Choisissez votre nouveau mot de passe

diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts index 7e212cd..a14cb78 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -6,6 +6,7 @@ import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; import { ResetPassword } from './reset-password'; import { AuthService } from '../../../core/services/auth.service'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; function configure(token: string | null) { return TestBed.configureTestingModule({ @@ -22,11 +23,17 @@ function configure(token: string | null) { } describe('ResetPassword', () => { - it("signale un lien incomplet quand le jeton est absent de l'URL", async () => { + it("redirige vers /login avec le motif standard quand le jeton est absent de l'URL", async () => { await configure(null); const fixture = TestBed.createComponent(ResetPassword); + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + + fixture.detectChanges(); expect(fixture.componentInstance.hasToken).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); }); it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => { @@ -59,16 +66,32 @@ describe('ResetPassword', () => { expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); }); - it('affiche un message dédié quand le lien est invalide ou expiré', async () => { + it('redirige vers /login avec le motif standard quand le lien est invalide ou expiré', async () => { await configure('un-secret-perime'); const fixture = TestBed.createComponent(ResetPassword); const component = fixture.componentInstance; const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); component.onSubmit(); + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); + }); + + it('affiche un message générique sur une erreur inattendue (pas 400)', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.onSubmit(); + expect(component.errorMessage()).toContain('invalide'); }); }); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts index 6754fa7..3fa56fa 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -1,9 +1,10 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; @Component({ selector: 'app-reset-password', @@ -12,7 +13,7 @@ import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/pa templateUrl: './reset-password.html', styleUrl: './reset-password.scss', }) -export class ResetPassword { +export class ResetPassword implements OnInit { private fb = inject(FormBuilder); private auth = inject(AuthService); private router = inject(Router); @@ -29,6 +30,12 @@ export class ResetPassword { new_password: ['', passwordValidators], }); + ngOnInit(): void { + if (!this.hasToken) { + this.redirigeVersLoginLienInvalide(); + } + } + onSubmit(): void { if (this.form.invalid || !this.hasToken) return; @@ -42,11 +49,15 @@ export class ResetPassword { error: (error: HttpErrorResponse) => { this.isLoading.set(false); if (error.status === 400) { - this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.'); + this.redirigeVersLoginLienInvalide(); return; } this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); }, }); } + + private redirigeVersLoginLienInvalide(): void { + this.router.navigate(['/login'], { queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE } }); + } } diff --git a/apps/frontend/src/app/shared/models/auth-redirect-reason.ts b/apps/frontend/src/app/shared/models/auth-redirect-reason.ts new file mode 100644 index 0000000..7feb0de --- /dev/null +++ b/apps/frontend/src/app/shared/models/auth-redirect-reason.ts @@ -0,0 +1,3 @@ +export const MOTIF_LIEN_RESET_INVALIDE = 'lien-expire'; +export const MESSAGE_LIEN_RESET_INVALIDE = + 'Ce lien de réinitialisation est invalide ou a expiré. Connectez-vous ou redemandez-en un.';