| @@ -1,9 +1,99 @@ | |||
| import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; | |||
| import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; | |||
| export const appConfig: ApplicationConfig = { | |||
| providers: [ | |||
| provideBrowserGlobalErrorListeners(), | |||
| provideZoneChangeDetection({ eventCoalescing: true }), | |||
| ] | |||
| }; | |||
| export const APP_CONFIG = { | |||
| app: { | |||
| name: 'AIT', | |||
| version: '1.0.0', | |||
| defaultLanguage: 'fr', | |||
| }, | |||
| password: { | |||
| minLength: 4, | |||
| maxLength: 50, | |||
| requireUppercase: false, | |||
| requireLowercase: false, | |||
| requireNumbers: false, | |||
| requireSpecialChars: false, | |||
| }, | |||
| email: { | |||
| minLength: 5, | |||
| maxLength: 254, | |||
| }, | |||
| user: { | |||
| username: { | |||
| minLength: 1, | |||
| maxLength: 50, | |||
| pattern: /^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$/, // Email ou username | |||
| }, | |||
| firstName: { | |||
| minLength: 1, | |||
| maxLength: 50, | |||
| }, | |||
| lastName: { | |||
| minLength: 1, | |||
| maxLength: 50, | |||
| }, | |||
| }, | |||
| messages: { | |||
| defaultErrorMessage: 'Une erreur est survenue. Veuillez réessayer.', | |||
| defaultSuccessMessage: 'Opération effectuée avec succès.', | |||
| }, | |||
| pagination: { | |||
| defaultPageSize: 20, | |||
| pageSizeOptions: [10, 20, 50, 100], | |||
| }, | |||
| files: { | |||
| maxUploadSizeMB: 10, | |||
| allowedImageTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], | |||
| allowedDocumentTypes: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], | |||
| }, | |||
| } as const; | |||
| export type AppConfig = typeof APP_CONFIG; | |||
| export const VALIDATION_MESSAGES = { | |||
| password: { | |||
| required: 'Le mot de passe est obligatoire.', | |||
| minLength: `Le mot de passe doit contenir au moins ${APP_CONFIG.password.minLength} caractères.`, | |||
| maxLength: `Le mot de passe ne peut pas dépasser ${APP_CONFIG.password.maxLength} caractères.`, | |||
| mismatch: 'Les mots de passe ne correspondent pas.', | |||
| }, | |||
| email: { | |||
| required: "L'adresse e-mail est obligatoire.", | |||
| invalid: 'Veuillez entrer une adresse e-mail valide.', | |||
| minLength: `L'adresse e-mail doit contenir au moins ${APP_CONFIG.email.minLength} caractères.`, | |||
| maxLength: `L'adresse e-mail ne peut pas dépasser ${APP_CONFIG.email.maxLength} caractères.`, | |||
| }, | |||
| username: { | |||
| required: "Le nom d'utilisateur est obligatoire.", | |||
| minLength: `Le nom d'utilisateur doit contenir au moins ${APP_CONFIG.user.username.minLength} caractères.`, | |||
| maxLength: `Le nom d'utilisateur ne peut pas dépasser ${APP_CONFIG.user.username.maxLength} caractères.`, | |||
| pattern: "Le nom d'utilisateur ne peut contenir que des lettres, chiffres, tirets et underscores.", | |||
| }, | |||
| firstName: { | |||
| required: 'Le prénom est obligatoire.', | |||
| minLength: `Le prénom doit contenir au moins ${APP_CONFIG.user.firstName.minLength} caractère.`, | |||
| maxLength: `Le prénom ne peut pas dépasser ${APP_CONFIG.user.firstName.maxLength} caractères.`, | |||
| }, | |||
| lastName: { | |||
| required: 'Le nom est obligatoire.', | |||
| minLength: `Le nom doit contenir au moins ${APP_CONFIG.user.lastName.minLength} caractère.`, | |||
| maxLength: `Le nom ne peut pas dépasser ${APP_CONFIG.user.lastName.maxLength} caractères.`, | |||
| }, | |||
| } as const; | |||
| @@ -0,0 +1,161 @@ | |||
| <div class="auth-page-container forgot-password-page"> | |||
| <div class="auth-card"> | |||
| <!-- Header --> | |||
| <div class="auth-header"> | |||
| <div class="logo">{{APP_CONFIG.app.name}}</div> | |||
| <h1 class="auth-title">Nouveau mot de passe</h1> | |||
| <p class="auth-subtitle"> | |||
| Choisissez un nouveau mot de passe sécurisé pour votre compte. | |||
| </p> | |||
| </div> | |||
| <!-- Content --> | |||
| <div class="auth-content"> | |||
| @if (!initialized()) { | |||
| <!-- Loading State --> | |||
| <div class="form-field"> | |||
| <p class="info-message">Chargement en cours...</p> | |||
| </div> | |||
| } @else if (success()) { | |||
| <!-- Success Message --> | |||
| <div class="form-field"> | |||
| <div class="success-message"> | |||
| <strong>Votre mot de passe a été réinitialisé avec succès !</strong> | |||
| <br /> | |||
| Vous pouvez maintenant vous connecter avec votre nouveau mot de passe. | |||
| </div> | |||
| </div> | |||
| <!-- Login Link Button --> | |||
| <a routerLink="/login" class="auth-btn"> | |||
| <span> | |||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||
| <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path> | |||
| <polyline points="10 17 15 12 10 7"></polyline> | |||
| <line x1="15" y1="12" x2="3" y2="12"></line> | |||
| </svg> | |||
| Se connecter | |||
| </span> | |||
| </a> | |||
| } @else { | |||
| <!-- Reset Password Form --> | |||
| <form class="auth-form" [formGroup]="passwordForm" (ngSubmit)="finishReset()"> | |||
| <!-- Global Error Messages --> | |||
| @if (error()) { | |||
| <div class="form-field"> | |||
| <p class="error-message"> | |||
| <strong>Une erreur est survenue !</strong> | |||
| <br /> | |||
| La réinitialisation de votre mot de passe n'a pas pu être effectuée. Veuillez demander un nouveau lien de réinitialisation. | |||
| </p> | |||
| </div> | |||
| } | |||
| @if (doNotMatch()) { | |||
| <div class="form-field"> | |||
| <p class="error-message"> | |||
| <strong>{{ validationMessages.password.mismatch }}</strong> | |||
| <br /> | |||
| Veuillez vous assurer que les deux mots de passe sont identiques. | |||
| </p> | |||
| </div> | |||
| } | |||
| <!-- New Password Field --> | |||
| <div class="form-field"> | |||
| <label for="newPassword" class="form-label">Nouveau mot de passe</label> | |||
| <div class="input-wrapper"> | |||
| <input | |||
| #newPassword | |||
| type="password" | |||
| id="newPassword" | |||
| formControlName="newPassword" | |||
| class="form-input" | |||
| [class.error]="passwordForm.get('newPassword')?.invalid && passwordForm.get('newPassword')?.touched" | |||
| placeholder="Entrez votre nouveau mot de passe" | |||
| autocomplete="new-password" | |||
| /> | |||
| <span class="input-icon"> | |||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||
| <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> | |||
| <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> | |||
| </svg> | |||
| </span> | |||
| </div> | |||
| <!-- Error Messages for New Password --> | |||
| @if (passwordForm.get('newPassword')?.hasError('required') && passwordForm.get('newPassword')?.touched) { | |||
| <p class="error-message">{{ validationMessages.password.required }}</p> | |||
| } | |||
| @if (passwordForm.get('newPassword')?.hasError('minlength') && passwordForm.get('newPassword')?.touched) { | |||
| <p class="error-message">{{ validationMessages.password.minLength }}</p> | |||
| } | |||
| @if (passwordForm.get('newPassword')?.hasError('maxlength')) { | |||
| <p class="error-message">{{ validationMessages.password.maxLength }}</p> | |||
| } | |||
| <p class="info-message"> | |||
| Le mot de passe doit contenir entre {{ passwordConfig.minLength }} et {{ passwordConfig.maxLength }} caractères. | |||
| </p> | |||
| </div> | |||
| <!-- Confirm Password Field --> | |||
| <div class="form-field"> | |||
| <label for="confirmPassword" class="form-label">Confirmer le mot de passe</label> | |||
| <div class="input-wrapper"> | |||
| <input | |||
| type="password" | |||
| id="confirmPassword" | |||
| formControlName="confirmPassword" | |||
| class="form-input" | |||
| [class.error]="(passwordForm.get('confirmPassword')?.invalid && passwordForm.get('confirmPassword')?.touched) || doNotMatch()" | |||
| placeholder="Confirmez votre nouveau mot de passe" | |||
| autocomplete="new-password" | |||
| /> | |||
| <span class="input-icon"> | |||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||
| <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> | |||
| <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> | |||
| </svg> | |||
| </span> | |||
| </div> | |||
| <!-- Error Messages for Confirm Password --> | |||
| @if (passwordForm.get('confirmPassword')?.hasError('required') && passwordForm.get('confirmPassword')?.touched) { | |||
| <p class="error-message">{{ validationMessages.password.required }}</p> | |||
| } | |||
| @if (passwordForm.get('confirmPassword')?.hasError('minlength') && passwordForm.get('confirmPassword')?.touched) { | |||
| <p class="error-message">{{ validationMessages.password.minLength }}</p> | |||
| } | |||
| @if (passwordForm.get('confirmPassword')?.hasError('maxlength')) { | |||
| <p class="error-message">{{ validationMessages.password.maxLength }}</p> | |||
| } | |||
| </div> | |||
| <!-- Submit Button --> | |||
| <button | |||
| type="submit" | |||
| class="auth-btn" | |||
| [disabled]="passwordForm.invalid" | |||
| > | |||
| <span> | |||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||
| <polyline points="20 6 9 17 4 12"></polyline> | |||
| </svg> | |||
| Réinitialiser le mot de passe | |||
| </span> | |||
| </button> | |||
| </form> | |||
| } | |||
| </div> | |||
| <!-- Footer --> | |||
| <div class="auth-footer"> | |||
| <p> | |||
| Vous vous souvenez de votre mot de passe ? | |||
| <a routerLink="/login" class="back-to-login-link">Retour à la connexion</a> | |||
| </p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,98 @@ | |||
| import { ElementRef, signal } from '@angular/core'; | |||
| import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/core/testing'; | |||
| import { provideHttpClient } from '@angular/common/http'; | |||
| import { FormBuilder } from '@angular/forms'; | |||
| import { ActivatedRoute } from '@angular/router'; | |||
| import { of, throwError } from 'rxjs'; | |||
| import PasswordResetFinishComponent from './password-reset-finish.component'; | |||
| import { PasswordResetFinishService } from './password-reset-finish.service'; | |||
| describe('PasswordResetFinishComponent', () => { | |||
| let fixture: ComponentFixture<PasswordResetFinishComponent>; | |||
| let comp: PasswordResetFinishComponent; | |||
| beforeEach(() => { | |||
| fixture = TestBed.configureTestingModule({ | |||
| imports: [PasswordResetFinishComponent], | |||
| providers: [ | |||
| provideHttpClient(), | |||
| FormBuilder, | |||
| { | |||
| provide: ActivatedRoute, | |||
| useValue: { queryParams: of({ key: 'XYZPDQ' }) }, | |||
| }, | |||
| ], | |||
| }) | |||
| .overrideTemplate(PasswordResetFinishComponent, '') | |||
| .createComponent(PasswordResetFinishComponent); | |||
| }); | |||
| beforeEach(() => { | |||
| fixture = TestBed.createComponent(PasswordResetFinishComponent); | |||
| comp = fixture.componentInstance; | |||
| comp.ngOnInit(); | |||
| }); | |||
| it('should define its initial state', () => { | |||
| expect(comp.initialized()).toBe(true); | |||
| expect(comp.key()).toEqual('XYZPDQ'); | |||
| }); | |||
| it('sets focus after the view has been initialized', () => { | |||
| const node = { | |||
| focus: jest.fn(), | |||
| }; | |||
| comp.newPassword = signal<ElementRef>(new ElementRef(node)); | |||
| comp.ngAfterViewInit(); | |||
| expect(node.focus).toHaveBeenCalled(); | |||
| }); | |||
| it('should ensure the two passwords entered match', () => { | |||
| comp.passwordForm.patchValue({ | |||
| newPassword: 'password', | |||
| confirmPassword: 'non-matching', | |||
| }); | |||
| comp.finishReset(); | |||
| expect(comp.doNotMatch()).toBe(true); | |||
| }); | |||
| it('should update success to true after resetting password', inject( | |||
| [PasswordResetFinishService], | |||
| fakeAsync((service: PasswordResetFinishService) => { | |||
| jest.spyOn(service, 'save').mockReturnValue(of({})); | |||
| comp.passwordForm.patchValue({ | |||
| newPassword: 'password', | |||
| confirmPassword: 'password', | |||
| }); | |||
| comp.finishReset(); | |||
| tick(); | |||
| expect(service.save).toHaveBeenCalledWith('XYZPDQ', 'password'); | |||
| expect(comp.success()).toBe(true); | |||
| }), | |||
| )); | |||
| it('should notify of generic error', inject( | |||
| [PasswordResetFinishService], | |||
| fakeAsync((service: PasswordResetFinishService) => { | |||
| jest.spyOn(service, 'save').mockReturnValue(throwError(Error)); | |||
| comp.passwordForm.patchValue({ | |||
| newPassword: 'password', | |||
| confirmPassword: 'password', | |||
| }); | |||
| comp.finishReset(); | |||
| tick(); | |||
| expect(service.save).toHaveBeenCalledWith('XYZPDQ', 'password'); | |||
| expect(comp.success()).toBe(false); | |||
| expect(comp.error()).toBe(true); | |||
| }), | |||
| )); | |||
| }); | |||
| @@ -0,0 +1,78 @@ | |||
| import { AfterViewInit, Component, ElementRef, OnInit, inject, signal, viewChild } from '@angular/core'; | |||
| import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; | |||
| import { ActivatedRoute, RouterModule } from '@angular/router'; | |||
| import { PasswordResetFinishService } from './password-reset-finish.service'; | |||
| import SharedModule from '../../../shared/shared.module'; | |||
| import {APP_CONFIG, appConfig, VALIDATION_MESSAGES} from '../../../app.config'; | |||
| @Component({ | |||
| selector: 'jhi-password-reset-finish', | |||
| imports: [SharedModule, RouterModule, FormsModule, ReactiveFormsModule], | |||
| templateUrl: './password-reset-finish.component.html', | |||
| }) | |||
| export default class PasswordResetFinishComponent implements OnInit, AfterViewInit { | |||
| newPassword = viewChild.required<ElementRef>('newPassword'); | |||
| initialized = signal(false); | |||
| doNotMatch = signal(false); | |||
| error = signal(false); | |||
| success = signal(false); | |||
| key = signal(''); | |||
| readonly validationMessages = VALIDATION_MESSAGES; | |||
| readonly passwordConfig = APP_CONFIG.password; | |||
| passwordForm = new FormGroup({ | |||
| newPassword: new FormControl('', { | |||
| nonNullable: true, | |||
| validators: [ | |||
| Validators.required, | |||
| Validators.minLength(APP_CONFIG.password.minLength), | |||
| Validators.maxLength(APP_CONFIG.password.maxLength) | |||
| ], | |||
| }), | |||
| confirmPassword: new FormControl('', { | |||
| nonNullable: true, | |||
| validators: [ | |||
| Validators.required, | |||
| Validators.minLength(APP_CONFIG.password.minLength), | |||
| Validators.maxLength(APP_CONFIG.password.maxLength) | |||
| ], | |||
| }), | |||
| }); | |||
| private readonly passwordResetFinishService = inject(PasswordResetFinishService); | |||
| private readonly route = inject(ActivatedRoute); | |||
| ngOnInit(): void { | |||
| this.route.queryParams.subscribe(params => { | |||
| if (params["key"]) { | |||
| this.key.set(params["key"]); | |||
| } | |||
| this.initialized.set(true); | |||
| }); | |||
| } | |||
| ngAfterViewInit(): void { | |||
| this.newPassword().nativeElement.focus(); | |||
| } | |||
| finishReset(): void { | |||
| this.doNotMatch.set(false); | |||
| this.error.set(false); | |||
| const { newPassword, confirmPassword } = this.passwordForm.getRawValue(); | |||
| if (newPassword !== confirmPassword) { | |||
| this.doNotMatch.set(true); | |||
| } else { | |||
| this.passwordResetFinishService.save(this.key(), newPassword).subscribe({ | |||
| next: () => this.success.set(true), | |||
| error: () => this.error.set(true), | |||
| }); | |||
| } | |||
| } | |||
| protected readonly appConfig = appConfig; | |||
| protected readonly APP_CONFIG = APP_CONFIG; | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| import { Route } from '@angular/router'; | |||
| import PasswordResetFinishComponent from './password-reset-finish.component'; | |||
| const passwordResetFinishRoute: Route = { | |||
| path: 'reset/finish', | |||
| component: PasswordResetFinishComponent, | |||
| title: 'global.menu.account.password', | |||
| }; | |||
| export default passwordResetFinishRoute; | |||
| @@ -0,0 +1,45 @@ | |||
| import { TestBed } from '@angular/core/testing'; | |||
| import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; | |||
| import { provideHttpClient } from '@angular/common/http'; | |||
| import { ApplicationConfigService } from 'app/core/config/application-config.service'; | |||
| import { PasswordResetFinishService } from './password-reset-finish.service'; | |||
| describe('PasswordResetFinish Service', () => { | |||
| let service: PasswordResetFinishService; | |||
| let httpMock: HttpTestingController; | |||
| let applicationConfigService: ApplicationConfigService; | |||
| beforeEach(() => { | |||
| TestBed.configureTestingModule({ | |||
| providers: [provideHttpClient(), provideHttpClientTesting()], | |||
| }); | |||
| service = TestBed.inject(PasswordResetFinishService); | |||
| applicationConfigService = TestBed.inject(ApplicationConfigService); | |||
| httpMock = TestBed.inject(HttpTestingController); | |||
| }); | |||
| afterEach(() => { | |||
| httpMock.verify(); | |||
| }); | |||
| describe('Service methods', () => { | |||
| it('should call reset-password/finish endpoint with correct values', () => { | |||
| // GIVEN | |||
| const key = 'abc'; | |||
| const newPassword = 'password'; | |||
| // WHEN | |||
| service.save(key, newPassword).subscribe(); | |||
| const testRequest = httpMock.expectOne({ | |||
| method: 'POST', | |||
| url: applicationConfigService.getEndpointFor('api/account/reset-password/finish'), | |||
| }); | |||
| // THEN | |||
| expect(testRequest.request.body).toEqual({ key, newPassword }); | |||
| }); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,15 @@ | |||
| import { Injectable, inject } from '@angular/core'; | |||
| import { HttpClient } from '@angular/common/http'; | |||
| import { Observable } from 'rxjs'; | |||
| import {ApplicationConfigService} from '../../../core/config/application-config.service'; | |||
| @Injectable({ providedIn: 'root' }) | |||
| export class PasswordResetFinishService { | |||
| private readonly http = inject(HttpClient); | |||
| private readonly applicationConfigService = inject(ApplicationConfigService); | |||
| save(key: string, newPassword: string): Observable<{}> { | |||
| return this.http.post(this.applicationConfigService.getEndpointFor('api/account/reset-password/finish'), { key, newPassword }); | |||
| } | |||
| } | |||
| @@ -0,0 +1,88 @@ | |||
| <div class="auth-page-container forgot-password-page"> | |||
| <div class="auth-card"> | |||
| <!-- Header --> | |||
| <div class="auth-header"> | |||
| <div class="logo">{{APP_CONFIG.app.name}}</div> | |||
| <h1 class="auth-title">Mot de passe oublié ?</h1> | |||
| <p class="auth-subtitle"> | |||
| Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe. | |||
| </p> | |||
| </div> | |||
| <!-- Content --> | |||
| <div class="auth-content"> | |||
| @if (success()) { | |||
| <!-- Success Message --> | |||
| <div class="form-field"> | |||
| <div class="success-message"> | |||
| <strong>Un e-mail de réinitialisation a été envoyé !</strong> | |||
| <br /> | |||
| Veuillez consulter votre boîte de réception et suivre les instructions pour réinitialiser votre mot de passe. | |||
| </div> | |||
| </div> | |||
| } @else { | |||
| <!-- Reset Form --> | |||
| <form class="auth-form" [formGroup]="resetRequestForm" (ngSubmit)="requestReset()"> | |||
| <!-- Email Field --> | |||
| <div class="form-field"> | |||
| <label for="email" class="form-label">Adresse e-mail</label> | |||
| <div class="input-wrapper"> | |||
| <input | |||
| type="email" | |||
| id="email" | |||
| formControlName="email" | |||
| class="form-input" | |||
| [class.error]="resetRequestForm.get('email')?.invalid && resetRequestForm.get('email')?.touched" | |||
| placeholder="votre@email.com" | |||
| autocomplete="email" | |||
| /> | |||
| <span class="input-icon"> | |||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||
| <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path> | |||
| <polyline points="22,6 12,13 2,6"></polyline> | |||
| </svg> | |||
| </span> | |||
| </div> | |||
| <!-- Error Messages --> | |||
| @if (resetRequestForm.get('email')?.hasError('required') && resetRequestForm.get('email')?.touched) { | |||
| <p class="error-message">{{ validationMessages.email.required }}</p> | |||
| } | |||
| @if (resetRequestForm.get('email')?.hasError('email') && resetRequestForm.get('email')?.touched) { | |||
| <p class="error-message">{{ validationMessages.email.invalid }}</p> | |||
| } | |||
| @if (resetRequestForm.get('email')?.hasError('minlength') && resetRequestForm.get('email')?.touched) { | |||
| <p class="error-message">{{ validationMessages.email.minLength }}</p> | |||
| } | |||
| @if (resetRequestForm.get('email')?.hasError('maxlength')) { | |||
| <p class="error-message">{{ validationMessages.email.maxLength }}</p> | |||
| } | |||
| </div> | |||
| <!-- Submit Button --> | |||
| <button | |||
| type="submit" | |||
| class="auth-btn" | |||
| [disabled]="resetRequestForm.invalid" | |||
| > | |||
| <span> | |||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||
| <path d="M22 12h-4l-3 9L9 3l-3 9H2"></path> | |||
| </svg> | |||
| Envoyer le lien de réinitialisation | |||
| </span> | |||
| </button> | |||
| </form> | |||
| } | |||
| </div> | |||
| <!-- Footer --> | |||
| <div class="auth-footer"> | |||
| <p> | |||
| Vous vous souvenez de votre mot de passe ? | |||
| <a routerLink="/login" class="back-to-login-link">Retour à la connexion</a> | |||
| </p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,58 @@ | |||
| import { ElementRef, signal } from '@angular/core'; | |||
| import { ComponentFixture, TestBed, inject } from '@angular/core/testing'; | |||
| import { provideHttpClient } from '@angular/common/http'; | |||
| import { FormBuilder } from '@angular/forms'; | |||
| import { of, throwError } from 'rxjs'; | |||
| import PasswordResetInitComponent from './password-reset-init.component'; | |||
| import { PasswordResetInitService } from './password-reset-init.service'; | |||
| describe('PasswordResetInitComponent', () => { | |||
| let fixture: ComponentFixture<PasswordResetInitComponent>; | |||
| let comp: PasswordResetInitComponent; | |||
| beforeEach(() => { | |||
| fixture = TestBed.configureTestingModule({ | |||
| imports: [PasswordResetInitComponent], | |||
| providers: [provideHttpClient(), FormBuilder], | |||
| }) | |||
| .overrideTemplate(PasswordResetInitComponent, '') | |||
| .createComponent(PasswordResetInitComponent); | |||
| comp = fixture.componentInstance; | |||
| }); | |||
| it('sets focus after the view has been initialized', () => { | |||
| const node = { | |||
| focus: jest.fn(), | |||
| }; | |||
| comp.email = signal<ElementRef>(new ElementRef(node)); | |||
| comp.ngAfterViewInit(); | |||
| expect(node.focus).toHaveBeenCalled(); | |||
| }); | |||
| it('notifies of success upon successful requestReset', inject([PasswordResetInitService], (service: PasswordResetInitService) => { | |||
| jest.spyOn(service, 'save').mockReturnValue(of({})); | |||
| comp.resetRequestForm.patchValue({ | |||
| email: 'user@domain.com', | |||
| }); | |||
| comp.requestReset(); | |||
| expect(service.save).toHaveBeenCalledWith('user@domain.com'); | |||
| expect(comp.success()).toBe(true); | |||
| })); | |||
| it('no notification of success upon error response', inject([PasswordResetInitService], (service: PasswordResetInitService) => { | |||
| const err = { status: 503, data: 'something else' }; | |||
| jest.spyOn(service, 'save').mockReturnValue(throwError(() => err)); | |||
| comp.resetRequestForm.patchValue({ | |||
| email: 'user@domain.com', | |||
| }); | |||
| comp.requestReset(); | |||
| expect(service.save).toHaveBeenCalledWith('user@domain.com'); | |||
| expect(comp.success()).toBe(false); | |||
| })); | |||
| }); | |||
| @@ -0,0 +1,49 @@ | |||
| import { AfterViewInit, Component, ElementRef, inject, signal, viewChild } from '@angular/core'; | |||
| import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; | |||
| import { PasswordResetInitService } from './password-reset-init.service'; | |||
| import { RouterLink } from '@angular/router'; | |||
| import SharedModule from '../../../shared/shared.module'; | |||
| import {APP_CONFIG, VALIDATION_MESSAGES} from '../../../app.config'; | |||
| @Component({ | |||
| selector: 'jhi-password-reset-init', | |||
| imports: [SharedModule, FormsModule, ReactiveFormsModule, RouterLink], | |||
| templateUrl: './password-reset-init.component.html', | |||
| }) | |||
| export default class PasswordResetInitComponent implements AfterViewInit { | |||
| email = viewChild.required<ElementRef>('email'); | |||
| success = signal(false); | |||
| resetRequestForm; | |||
| readonly validationMessages = VALIDATION_MESSAGES; | |||
| readonly emailConfig = APP_CONFIG.email; | |||
| private readonly passwordResetInitService = inject(PasswordResetInitService); | |||
| private readonly fb = inject(FormBuilder); | |||
| constructor() { | |||
| this.resetRequestForm = this.fb.group({ | |||
| email: [ | |||
| '', | |||
| [ | |||
| Validators.required, | |||
| Validators.minLength(APP_CONFIG.email.minLength), | |||
| Validators.maxLength(APP_CONFIG.email.maxLength), | |||
| Validators.email | |||
| ] | |||
| ], | |||
| }); | |||
| } | |||
| ngAfterViewInit(): void { | |||
| this.email().nativeElement.focus(); | |||
| } | |||
| requestReset(): void { | |||
| this.passwordResetInitService.save(this.resetRequestForm.get(['email'])!.value).subscribe(() => this.success.set(true)); | |||
| } | |||
| protected readonly APP_CONFIG = APP_CONFIG; | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| import { Route } from '@angular/router'; | |||
| import PasswordResetInitComponent from './password-reset-init.component'; | |||
| const passwordResetInitRoute: Route = { | |||
| path: 'reset/request', | |||
| component: PasswordResetInitComponent, | |||
| title: 'global.menu.account.password', | |||
| }; | |||
| export default passwordResetInitRoute; | |||
| @@ -0,0 +1,44 @@ | |||
| import { TestBed } from '@angular/core/testing'; | |||
| import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; | |||
| import { provideHttpClient } from '@angular/common/http'; | |||
| import { ApplicationConfigService } from 'app/core/config/application-config.service'; | |||
| import { PasswordResetInitService } from './password-reset-init.service'; | |||
| describe('PasswordResetInit Service', () => { | |||
| let service: PasswordResetInitService; | |||
| let httpMock: HttpTestingController; | |||
| let applicationConfigService: ApplicationConfigService; | |||
| beforeEach(() => { | |||
| TestBed.configureTestingModule({ | |||
| providers: [provideHttpClient(), provideHttpClientTesting()], | |||
| }); | |||
| service = TestBed.inject(PasswordResetInitService); | |||
| applicationConfigService = TestBed.inject(ApplicationConfigService); | |||
| httpMock = TestBed.inject(HttpTestingController); | |||
| }); | |||
| afterEach(() => { | |||
| httpMock.verify(); | |||
| }); | |||
| describe('Service methods', () => { | |||
| it('should call reset-password/init endpoint with correct values', () => { | |||
| // GIVEN | |||
| const mail = 'test@test.com'; | |||
| // WHEN | |||
| service.save(mail).subscribe(); | |||
| const testRequest = httpMock.expectOne({ | |||
| method: 'POST', | |||
| url: applicationConfigService.getEndpointFor('api/account/reset-password/init'), | |||
| }); | |||
| // THEN | |||
| expect(testRequest.request.body).toEqual(mail); | |||
| }); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,15 @@ | |||
| import { Injectable, inject } from '@angular/core'; | |||
| import { HttpClient } from '@angular/common/http'; | |||
| import { Observable } from 'rxjs'; | |||
| import {ApplicationConfigService} from '../../../core/config/application-config.service'; | |||
| @Injectable({ providedIn: 'root' }) | |||
| export class PasswordResetInitService { | |||
| private readonly http = inject(HttpClient); | |||
| private readonly applicationConfigService = inject(ApplicationConfigService); | |||
| save(mail: string): Observable<{}> { | |||
| return this.http.post(this.applicationConfigService.getEndpointFor('api/account/reset-password/init'), mail); | |||
| } | |||
| } | |||