Merge pull request 'feat : mot de passe oublié' (#23) from ttp-17 into main
Reviewed-on: #23 Reviewed-by: technostrea <contact@technostrea.fr> #17
This commit is contained in:
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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é ?
|
||||||
|
|||||||
Reference in New Issue
Block a user