fix(frontend): un lien de reset absent ou expire renvoie vers login avec un message standard
Avant, un token absent affichait un message inline sur /reset-password, et un token invalide/expire ne se voyait qu'apres soumission du formulaire. Les deux cas redirigent maintenant vers /login avec le motif "lien-expire", qui y affiche le message standard "Ce lien de reinitialisation est invalide ou a expire. Connectez-vous ou redemandez-en un."
This commit is contained in:
@@ -1,28 +1,43 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
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 { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { Login } from './login';
|
import { Login } from './login';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||||
|
|
||||||
|
function configure(queryParams: Record<string, string> = {}) {
|
||||||
|
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', () => {
|
describe('Login', () => {
|
||||||
let authMock: { login: ReturnType<typeof vi.fn> };
|
let authMock: { login: ReturnType<typeof vi.fn> };
|
||||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
authMock = { login: vi.fn() };
|
const attirail = configure();
|
||||||
routerMock = { navigate: vi.fn() };
|
authMock = attirail.authMock;
|
||||||
|
routerMock = attirail.routerMock;
|
||||||
await TestBed.configureTestingModule({
|
await attirail.testBed.compileComponents();
|
||||||
imports: [Login, ReactiveFormsModule],
|
|
||||||
providers: [
|
|
||||||
{ provide: AuthService, useValue: authMock },
|
|
||||||
{ provide: Router, useValue: routerMock },
|
|
||||||
{ provide: ActivatedRoute, useValue: {} },
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ne soumet pas si le formulaire est invalide', () => {
|
it('ne soumet pas si le formulaire est invalide', () => {
|
||||||
@@ -85,6 +100,14 @@ describe('Login', () => {
|
|||||||
expect(errorEl?.textContent).toContain('30s');
|
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', () => {
|
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||||
const fixture = TestBed.createComponent(Login);
|
const fixture = TestBed.createComponent(Login);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
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 { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -15,8 +16,13 @@ export class Login {
|
|||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
errorMessage = signal<string | null>(null);
|
errorMessage = signal<string | null>(
|
||||||
|
this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE
|
||||||
|
? MESSAGE_LIEN_RESET_INVALIDE
|
||||||
|
: null,
|
||||||
|
);
|
||||||
retryAfterSeconds = signal<number | null>(null);
|
retryAfterSeconds = signal<number | null>(null);
|
||||||
isLoading = signal(false);
|
isLoading = signal(false);
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
|
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
<h1>Nouveau mot de passe</h1>
|
<h1>Nouveau mot de passe</h1>
|
||||||
|
|
||||||
@if (!hasToken) {
|
@if (hasToken) {
|
||||||
<p class="auth-error">Ce lien est incomplet. Redemandez un lien de réinitialisation.</p>
|
|
||||||
} @else {
|
|
||||||
<p class="auth-subtitle">Choisissez votre nouveau mot de passe</p>
|
<p class="auth-subtitle">Choisissez votre nouveau mot de passe</p>
|
||||||
|
|
||||||
<label for="new_password">Nouveau mot de passe</label>
|
<label for="new_password">Nouveau mot de passe</label>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { of, throwError } from 'rxjs';
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { ResetPassword } from './reset-password';
|
import { ResetPassword } from './reset-password';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||||
|
|
||||||
function configure(token: string | null) {
|
function configure(token: string | null) {
|
||||||
return TestBed.configureTestingModule({
|
return TestBed.configureTestingModule({
|
||||||
@@ -22,11 +23,17 @@ function configure(token: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ResetPassword', () => {
|
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);
|
await configure(null);
|
||||||
const fixture = TestBed.createComponent(ResetPassword);
|
const fixture = TestBed.createComponent(ResetPassword);
|
||||||
|
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.hasToken).toBe(false);
|
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 () => {
|
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']);
|
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');
|
await configure('un-secret-perime');
|
||||||
const fixture = TestBed.createComponent(ResetPassword);
|
const fixture = TestBed.createComponent(ResetPassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
|
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
|
||||||
|
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
|
||||||
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
|
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||||
auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 })));
|
auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 })));
|
||||||
|
|
||||||
component.onSubmit();
|
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<typeof vi.fn> };
|
||||||
|
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');
|
expect(component.errorMessage()).toContain('invalide');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { ReactiveFormsModule, FormBuilder } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||||
|
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-reset-password',
|
selector: 'app-reset-password',
|
||||||
@@ -12,7 +13,7 @@ import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/pa
|
|||||||
templateUrl: './reset-password.html',
|
templateUrl: './reset-password.html',
|
||||||
styleUrl: './reset-password.scss',
|
styleUrl: './reset-password.scss',
|
||||||
})
|
})
|
||||||
export class ResetPassword {
|
export class ResetPassword implements OnInit {
|
||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -29,6 +30,12 @@ export class ResetPassword {
|
|||||||
new_password: ['', passwordValidators],
|
new_password: ['', passwordValidators],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!this.hasToken) {
|
||||||
|
this.redirigeVersLoginLienInvalide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid || !this.hasToken) return;
|
if (this.form.invalid || !this.hasToken) return;
|
||||||
|
|
||||||
@@ -42,11 +49,15 @@ export class ResetPassword {
|
|||||||
error: (error: HttpErrorResponse) => {
|
error: (error: HttpErrorResponse) => {
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
if (error.status === 400) {
|
if (error.status === 400) {
|
||||||
this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.');
|
this.redirigeVersLoginLienInvalide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`);
|
this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private redirigeVersLoginLienInvalide(): void {
|
||||||
|
this.router.navigate(['/login'], { queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
Reference in New Issue
Block a user