feat : mot de passe oublié

This commit is contained in:
styve Lioumba
2025-11-27 22:09:14 +01:00
parent 945a21f511
commit d72c6e60a8
6 changed files with 260 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
import { RegisterComponent } from '@app/shared/features/register/register.component'; import { RegisterComponent } from '@app/shared/features/register/register.component';
import { AuthComponent } from '@app/routes/authentification/auth/auth.component'; import { AuthComponent } from '@app/routes/authentification/auth/auth.component';
import { LoginComponent } from '@app/shared/features/login/login.component'; import { LoginComponent } from '@app/shared/features/login/login.component';
import { ForgotPasswordComponent } from '@app/shared/features/forgot-password/forgot-password.component';
const routes: Routes = [ const routes: Routes = [
{ {
@@ -11,6 +12,7 @@ const routes: Routes = [
children: [ children: [
{ path: '', component: LoginComponent }, { path: '', component: LoginComponent },
{ path: 'register', component: RegisterComponent }, { path: 'register', component: RegisterComponent },
{ path: 'forgot-password', component: ForgotPasswordComponent },
], ],
}, },
{ {

View File

@@ -0,0 +1,58 @@
<form [formGroup]="fpForm" (ngSubmit)="onSubmit()" class="space-y-4">
<!-- Champ Email -->
<div class="space-y-2">
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Adresse email
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
</div>
<input
formControlName="email"
type="email"
id="email"
name="email"
placeholder="votre@email.com"
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
</div>
@if (fpForm.get('email')?.invalid && fpForm.get('email')?.touched) {
<p class="text-xs text-red-500 mt-1">Veuillez entrer une adresse email valide</p>
}
</div>
<!-- Bouton de connexion -->
<button
type="submit"
[disabled]="fpForm.invalid || loading().isLoading"
class="w-full py-3 px-4 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white font-medium rounded-lg shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
@if (loading().isLoading) {
<app-btn-loading message="Demande en cours..." />
} @else {
Soumettre
}
</button>
<!-- Lien vers l'inscription -->
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
Vous n'avez pas de compte ?
<a
[routerLink]="['register']"
class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 font-semibold transition-colors"
>
Créez-en un ici
</a>
</p>
</div>
</form>

View File

@@ -0,0 +1,119 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ForgotPasswordComponent } from './forgot-password.component';
import { ToastrService } from 'ngx-toastr';
import { signal, WritableSignal } from '@angular/core';
import { ActionType } from '@app/domain/action-type.util';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { Subject } from 'rxjs';
describe('ForgotPasswordComponent', () => {
let component: ForgotPasswordComponent;
let fixture: ComponentFixture<ForgotPasswordComponent>;
let mockToastrService: Partial<ToastrService>;
let mockRouter: any;
let mockActivatedRoute: any;
let mockAuthFacade: {
sendRequestPasswordReset: jest.Mock;
isRequestPasswordSent: jest.Mock; // <--- AJOUT
loading: WritableSignal<{ isLoading: boolean; action: ActionType }>;
error: WritableSignal<{ hasError: boolean }>;
};
beforeEach(async () => {
mockToastrService = {
success: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
};
// 1. CORRECTION DU MOCK ROUTER
mockRouter = {
navigate: jest.fn().mockResolvedValue(true),
createUrlTree: jest.fn(),
serializeUrl: jest.fn(),
routerState: { root: '' },
events: new Subject(), // <--- AJOUT CRUCIAL : RouterLink s'abonne à ceci
};
mockActivatedRoute = {
snapshot: { paramMap: { get: () => null } },
};
mockAuthFacade = {
sendRequestPasswordReset: jest.fn(),
isRequestPasswordSent: jest.fn().mockReturnValue(true),
loading: signal({ isLoading: false, action: ActionType.NONE }),
error: signal({ hasError: false }),
};
await TestBed.configureTestingModule({
imports: [ForgotPasswordComponent],
providers: [
// provideRouter([]) est inutile si on écrase Router juste en dessous,
// mais on peut le laisser par sécurité si d'autres deps en ont besoin.
provideRouter([]),
{ provide: ToastrService, useValue: mockToastrService },
{ provide: AuthFacade, useValue: mockAuthFacade },
{ provide: Router, useValue: mockRouter }, // On remplace le vrai routeur par notre mock
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
],
}).compileComponents();
fixture = TestBed.createComponent(ForgotPasswordComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should NOT call sendRequestPasswordReset if form is invalid', () => {
component.fpForm.setValue({ email: 'bad-email' });
component.onSubmit();
expect(component.fpForm.invalid).toBe(true);
expect(mockAuthFacade.sendRequestPasswordReset).not.toHaveBeenCalled();
});
it('should call sendRequestPasswordReset if form is valid', () => {
const validEmail = 'test@example.com';
component.fpForm.setValue({ email: validEmail });
component.onSubmit();
expect(mockAuthFacade.sendRequestPasswordReset).toHaveBeenCalledWith(validEmail);
});
it('should show success toast and navigate to /auth when action finishes successfully', () => {
component.fpForm.setValue({ email: 'success@test.com' });
// On s'assure que la condition isRequestPasswordSent() renvoie bien TRUE
mockAuthFacade.isRequestPasswordSent.mockReturnValue(true);
mockAuthFacade.error.set({ hasError: false });
mockAuthFacade.loading.set({ isLoading: false, action: ActionType.CREATE });
fixture.detectChanges();
expect(mockToastrService.success).toHaveBeenCalledWith(
expect.stringContaining('success@test.com'),
expect.anything(),
expect.anything()
);
expect(mockRouter.navigate).toHaveBeenCalledWith(['/auth']);
});
it('should show error toast when action finishes with error', () => {
component.fpForm.setValue({ email: 'error@test.com' });
mockAuthFacade.error.set({ hasError: true });
mockAuthFacade.loading.set({ isLoading: false, action: ActionType.CREATE });
fixture.detectChanges();
expect(mockToastrService.error).toHaveBeenCalled();
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockToastrService.success).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
import { Component, effect, inject } from '@angular/core';
import { BtnLoadingComponent } from '@app/shared/components/btn-loading/btn-loading.component';
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { ToastrService } from 'ngx-toastr';
import { ActionType } from '@app/domain/action-type.util';
@Component({
selector: 'app-forgot-password',
standalone: true,
imports: [BtnLoadingComponent, FormsModule, ReactiveFormsModule, RouterLink],
templateUrl: './forgot-password.component.html',
styleUrl: './forgot-password.component.scss',
})
export class ForgotPasswordComponent {
private readonly fb = inject(FormBuilder);
private readonly facade = inject(AuthFacade);
private readonly toastrService = inject(ToastrService);
protected readonly router = inject(Router);
fpForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
protected readonly loading = this.facade.loading;
protected readonly error = this.facade.error;
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.CREATE:
message = `Un mail de réinitialisation vous a été envoyé à cette adresse mail : ${this.fpForm.getRawValue().email!}`;
this.customToast(ActionType.CREATE, message);
if (!this.error().hasError && this.facade.isRequestPasswordSent()) {
this.router.navigate(['/auth']);
}
break;
}
}
});
}
onSubmit() {
if (this.fpForm.invalid) {
this.fpForm.setErrors({ invalidForm: true });
return;
}
this.facade.sendRequestPasswordReset(this.fpForm.getRawValue().email!);
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
this.toastrService.success(
`${message}`,
`${action === ActionType.CREATE ? 'Mise à jour' : ''}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
disableTimeOut: true,
}
);
}
}

View File

@@ -106,7 +106,7 @@
<!-- Mot de passe oublié --> <!-- Mot de passe oublié -->
<div class="flex justify-end"> <div class="flex justify-end">
<a <a
[routerLink]="['/forgot-password']" [routerLink]="['forgot-password']"
class="text-sm text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium transition-colors" class="text-sm text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium transition-colors"
> >
Mot de passe oublié ? Mot de passe oublié ?