From 9dcedc9382d13a6c3a5e812e993fe8c55889a2fa Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 27 Jul 2026 09:42:22 -0300 Subject: [PATCH] Forgot password flow --- src/app/app.routes.spec.ts | 49 +++ src/app/core/services/auth/auth.interfaces.ts | 22 ++ .../core/services/auth/auth.service.spec.ts | 117 +++++++ src/app/core/services/auth/auth.service.ts | 44 ++- .../login-page/login-page.component.html | 8 +- .../pages/login-page/login-page.component.ts | 4 + .../recover-password-code-page.component.html | 106 ++++++ .../recover-password-code-page.component.scss | 65 ++++ ...cover-password-code-page.component.spec.ts | 313 ++++++++++++++++++ .../recover-password-code-page.component.ts | 191 +++++++++++ .../recover-password-page.component.html | 53 +++ .../recover-password-page.component.scss | 17 + .../recover-password-page.component.spec.ts | 139 ++++++++ .../recover-password-page.component.ts | 135 ++++++++ .../reset-password-page.component.html | 56 ++++ .../reset-password-page.component.scss | 7 + .../reset-password-page.component.spec.ts | 148 +++++++++ .../reset-password-page.component.ts | 193 +++++++++++ src/app/features/store/store.routes.ts | 22 ++ .../components/input/input.component.html | 1 + .../components/input/input.component.ts | 9 + 21 files changed, 1695 insertions(+), 4 deletions(-) create mode 100644 src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.html create mode 100644 src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.scss create mode 100644 src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.spec.ts create mode 100644 src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.ts create mode 100644 src/app/features/store/pages/recover-password-page/recover-password-page.component.html create mode 100644 src/app/features/store/pages/recover-password-page/recover-password-page.component.scss create mode 100644 src/app/features/store/pages/recover-password-page/recover-password-page.component.spec.ts create mode 100644 src/app/features/store/pages/recover-password-page/recover-password-page.component.ts create mode 100644 src/app/features/store/pages/reset-password-page/reset-password-page.component.html create mode 100644 src/app/features/store/pages/reset-password-page/reset-password-page.component.scss create mode 100644 src/app/features/store/pages/reset-password-page/reset-password-page.component.spec.ts create mode 100644 src/app/features/store/pages/reset-password-page/reset-password-page.component.ts diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index 82c5b6f..f5861a0 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -165,6 +165,55 @@ describe('app routes', () => { ]); }); + it('loads the recover password page inside the simple layout', async () => { + const { fixture, router } = await renderAppAt('/recuperar-contrasena'); + const compiled = fixture.nativeElement as HTMLElement; + const recoverPasswordPage = compiled.querySelector('app-recover-password-page'); + + expect(router.url).toBe('/recuperar-contrasena'); + expect(compiled.querySelector('.simple-layout')).not.toBeNull(); + expect(recoverPasswordPage).not.toBeNull(); + expect(recoverPasswordPage?.textContent).toContain('Recuperar contraseña'); + expect(recoverPasswordPage?.querySelector('input')?.getAttribute('placeholder')).toBe('Email'); + expect(recoverPasswordPage?.textContent).toContain('Recuperar acceso'); + expect(recoverPasswordPage?.textContent).toContain('Volver'); + }); + + it('loads the recovery code page and shows the entered email', async () => { + const { fixture, router } = await renderAppAt( + '/recuperar-contrasena/codigo?email=ada%40example.com' + ); + const compiled = fixture.nativeElement as HTMLElement; + const codePage = compiled.querySelector('app-recover-password-code-page'); + + expect(router.url).toBe('/recuperar-contrasena/codigo?email=ada%40example.com'); + expect(compiled.querySelector('.simple-layout')).not.toBeNull(); + expect(codePage).not.toBeNull(); + expect(codePage?.textContent).toContain('ada@example.com'); + expect(codePage?.querySelectorAll('app-input')).toHaveLength(4); + expect(codePage?.textContent).toContain('Validar'); + }); + + it('loads the reset password page with the shared password inputs', async () => { + const { fixture, router } = await renderAppAt( + '/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234' + ); + const compiled = fixture.nativeElement as HTMLElement; + const resetPage = compiled.querySelector('app-reset-password-page'); + const placeholders = Array.from(resetPage?.querySelectorAll('input') ?? []).map((input) => + input.getAttribute('placeholder') + ); + + expect(router.url).toBe( + '/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234' + ); + expect(compiled.querySelector('.simple-layout')).not.toBeNull(); + expect(resetPage).not.toBeNull(); + expect(resetPage?.textContent).toContain('Restablecer contraseña'); + expect(placeholders).toEqual(['Nueva Contraseña', 'Repetir Nueva Contraseña']); + expect(resetPage?.textContent).toContain('Guardar'); + }); + it('redirects authenticated users away from /login', async () => { const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true)); diff --git a/src/app/core/services/auth/auth.interfaces.ts b/src/app/core/services/auth/auth.interfaces.ts index 80ae087..aa8d486 100644 --- a/src/app/core/services/auth/auth.interfaces.ts +++ b/src/app/core/services/auth/auth.interfaces.ts @@ -37,3 +37,25 @@ export interface RegisterResponse { message: string; data: AuthUser; } + +export interface ResetPasswordAttemptResponse { + message: string; + status: 'pending'; +} + +export interface ValidateResetPasswordAttemptResponse { + message: string; + status: 'validated'; +} + +export interface ResetPasswordPayload { + email: string; + codigo: string; + password: string; + password_confirmation: string; +} + +export interface ResetPasswordResponse { + message: string; + status: 'used'; +} diff --git a/src/app/core/services/auth/auth.service.spec.ts b/src/app/core/services/auth/auth.service.spec.ts index aaea6bc..1feaa71 100644 --- a/src/app/core/services/auth/auth.service.spec.ts +++ b/src/app/core/services/auth/auth.service.spec.ts @@ -174,6 +174,123 @@ describe('AuthService', () => { httpController.verify(); }); + it('requests a password reset and exposes the final HTTP status', () => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + { + provide: TenantService, + useValue: { getTenant: () => ({ codigo: 'tenant-test' }) } + }, + TransferState + ] + }); + + const service = TestBed.inject(AuthService); + const httpController = TestBed.inject(HttpTestingController); + + service.requestPasswordReset('ada@example.com').subscribe((response) => { + expect(response.status).toBe(202); + expect(response.body?.status).toBe('pending'); + }); + + const request = httpController.expectOne(`${environment.url}password/reset-attempts`); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + email: 'ada@example.com', + tenant_codigo: 'tenant-test' + }); + request.flush( + { + message: 'Si el email está registrado, recibirás un código.', + status: 'pending' + }, + { status: 202, statusText: 'Accepted' } + ); + + httpController.verify(); + }); + + it('validates a password reset code and exposes its final status', () => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + TransferState + ] + }); + + const service = TestBed.inject(AuthService); + const httpController = TestBed.inject(HttpTestingController); + + service.validatePasswordResetCode('ada@example.com', '0123').subscribe((response) => { + expect(response.status).toBe(200); + expect(response.body?.status).toBe('validated'); + }); + + const request = httpController.expectOne( + `${environment.url}password/reset-attempts/validate` + ); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + email: 'ada@example.com', + codigo: '0123' + }); + request.flush( + { + message: 'Código validado correctamente.', + status: 'validated' + }, + { status: 200, statusText: 'OK' } + ); + + httpController.verify(); + }); + + it('resets the password and exposes the consumed attempt status', () => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + TransferState + ] + }); + + const service = TestBed.inject(AuthService); + const httpController = TestBed.inject(HttpTestingController); + const payload = { + email: 'ada@example.com', + codigo: '0123', + password: 'NewSecret!456', + password_confirmation: 'NewSecret!456' + }; + + service.resetPassword(payload).subscribe((response) => { + expect(response.status).toBe(200); + expect(response.body?.status).toBe('used'); + }); + + const request = httpController.expectOne(`${environment.url}password/reset`); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(payload); + request.flush( + { + message: 'Contraseña modificada correctamente.', + status: 'used' + }, + { status: 200, statusText: 'OK' } + ); + + httpController.verify(); + }); + it('propagates logout errors without clearing the local session', () => { TestBed.configureTestingModule({ providers: [ diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index 00ab5d1..28ce48a 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; @@ -11,7 +11,11 @@ import { LoginResponse, RegisterPayload, RegisterResponse, - UpdateProfilePayload + ResetPasswordPayload, + ResetPasswordAttemptResponse, + ResetPasswordResponse, + UpdateProfilePayload, + ValidateResetPasswordAttemptResponse } from './auth.interfaces'; import { CookieService } from '../cookie/cookie.service'; import { TenantService } from '../tenant.service'; @@ -53,6 +57,42 @@ export class AuthService { }); } + requestPasswordReset( + email: string + ): Observable> { + const tenantCode = this.tenantService.getTenant()?.codigo; + + return this.http.post( + `${environment.url}password/reset-attempts`, + { + email, + ...(tenantCode ? { tenant_codigo: tenantCode } : {}) + }, + { observe: 'response' } + ); + } + + validatePasswordResetCode( + email: string, + codigo: string + ): Observable> { + return this.http.post( + `${environment.url}password/reset-attempts/validate`, + { email, codigo }, + { observe: 'response' } + ); + } + + resetPassword( + payload: ResetPasswordPayload + ): Observable> { + return this.http.post( + `${environment.url}password/reset`, + payload, + { observe: 'response' } + ); + } + loginWithGoogle(): void { const tenant = this.tenantService.getTenant(); if (!tenant) { diff --git a/src/app/features/store/pages/login-page/login-page.component.html b/src/app/features/store/pages/login-page/login-page.component.html index 776f5d8..3968dcf 100644 --- a/src/app/features/store/pages/login-page/login-page.component.html +++ b/src/app/features/store/pages/login-page/login-page.component.html @@ -40,7 +40,11 @@ {{ errorMessage }} }
-
@@ -56,7 +60,7 @@ > {{ isSubmitting() ? 'Ingresando...' : 'Ingresar' }} - + +

Ingresá el código

+

+ Te enviamos un código a {{ email }} +

+

+ Recordá revisar en Correo no deseado. +

+ + +
+
+
+ Código de recuperación de cuatro dígitos +
+ + + + +
+
+ + @if (isCodeInvalid()) { + + Ingresá los cuatro dígitos del código. + + } + + @if (serverError(); as errorMessage) { + + {{ errorMessage }} + + } + +
+ + {{ isResending() ? 'Reenviando...' : 'Reenviar Código' }} + +
+
+ +
+ + {{ isSubmitting() ? 'Validando...' : 'Validar' }} + + + + Volver + +
+
diff --git a/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.scss b/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.scss new file mode 100644 index 0000000..42ad962 --- /dev/null +++ b/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.scss @@ -0,0 +1,65 @@ +:host { + display: block; +} + +.recover-password-code-page__description { + color: #a0a0a0; + font-size: 12px; + font-weight: 325; +} + +.recover-password-code-page__form { + width: 100%; +} + +.recover-password-code-page__inputs app-input { + width: 52px; +} + +:host ::ng-deep .recover-password-code-page__inputs .form-control { + min-height: 52px; + padding: 0.5rem; + text-align: center; + font-size: 20px; +} + +:host ::ng-deep .recover-password-code-page__resend-action { + min-height: 36px; + color: #a0a0a0 !important; + font-size: 12px !important; + font-weight: 400 !important; + transition: + color 0.15s ease, + transform 0.1s ease; +} + +:host ::ng-deep .recover-password-code-page__resend-action:hover, +:host ::ng-deep .recover-password-code-page__resend-action:focus-visible { + color: #8a8a8a !important; +} + +:host ::ng-deep .recover-password-code-page__resend-action:hover { + transform: translateY(-1px); +} + +:host ::ng-deep .recover-password-code-page__resend-action:active { + color: #666666 !important; + transform: translateY(1px); +} + +:host ::ng-deep .recover-password-code-page__back-action { + min-height: 40px; + transition: + color 0.15s ease, + transform 0.1s ease; +} + +:host ::ng-deep .recover-password-code-page__back-action:hover, +:host ::ng-deep .recover-password-code-page__back-action:focus-visible { + transform: translateY(-1px); +} + +:host ::ng-deep .recover-password-code-page__back-action:active { + opacity: 0.75; + transform: translateY(1px); +} diff --git a/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.spec.ts b/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.spec.ts new file mode 100644 index 0000000..0929aa6 --- /dev/null +++ b/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.spec.ts @@ -0,0 +1,313 @@ +import { HttpResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { RecoverPasswordCodePageComponent } from './recover-password-code-page.component'; + +describe('RecoverPasswordCodePageComponent', () => { + let validatePasswordResetCode: ReturnType; + let requestPasswordReset: ReturnType; + let showDangerToast: ReturnType; + let showSuccessToast: ReturnType; + + beforeEach(() => { + TestBed.resetTestingModule(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + validatePasswordResetCode = vi.fn(() => of(new HttpResponse({ + body: { + message: 'Código validado correctamente.', + status: 'validated' as const, + }, + status: 200, + }))); + requestPasswordReset = vi.fn(() => of(new HttpResponse({ + body: { + message: 'Solicitud aceptada.', + status: 'pending' as const, + }, + status: 202, + }))); + showDangerToast = vi.fn(); + showSuccessToast = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function authProviders() { + return [ + { + provide: AuthService, + useValue: { requestPasswordReset, validatePasswordResetCode }, + }, + { + provide: ToastService, + useValue: { danger: showDangerToast, success: showSuccessToast }, + }, + ]; + } + + it('shows the email received from the recovery flow', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordCodePageComponent], + providers: [ + provideRouter([]), + ...authProviders(), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ email: 'ada@example.com' }), + }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('ada@example.com'); + }); + + it('moves focus to the next input after entering a digit', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordCodePageComponent], + providers: [ + provideRouter([]), + ...authProviders(), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ email: 'ada@example.com' }), + }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent); + const component = fixture.componentInstance as any; + fixture.detectChanges(); + const inputs = fixture.nativeElement.querySelectorAll('input'); + + inputs[0].focus(); + component.updateDigit('digit1', '1'); + + expect(document.activeElement).toBe(inputs[1]); + }); + + it('distributes a pasted four-digit code across all inputs', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordCodePageComponent], + providers: [ + provideRouter([]), + ...authProviders(), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ email: 'ada@example.com' }), + }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent); + const component = fixture.componentInstance as any; + fixture.detectChanges(); + const inputs = fixture.nativeElement.querySelectorAll('input'); + const pasteEvent = new Event('paste', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + getData: () => '01 23', + }, + }); + + inputs[0].dispatchEvent(pasteEvent); + fixture.detectChanges(); + + expect(pasteEvent.defaultPrevented).toBe(true); + expect(component.form.getRawValue()).toEqual({ + digit1: '0', + digit2: '1', + digit3: '2', + digit4: '3', + }); + expect(component.form.valid).toBe(true); + expect(document.activeElement).toBe(inputs[3]); + }); + + it('accepts only a complete four-digit code and navigates back preserving the email', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordCodePageComponent], + providers: [ + provideRouter([]), + ...authProviders(), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ email: 'ada@example.com' }), + }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.updateDigit('digit1', '1'); + component.updateDigit('digit2', 'a2'); + component.updateDigit('digit3', '3'); + component.updateDigit('digit4', '4'); + + expect(component.form.getRawValue()).toEqual({ + digit1: '1', + digit2: '2', + digit3: '3', + digit4: '4', + }); + expect(component.form.valid).toBe(true); + + component.goBack(); + + expect(navigateSpy).toHaveBeenCalledWith(['/recuperar-contrasena'], { + queryParams: { email: 'ada@example.com' }, + }); + }); + + it('continues to the reset password page with the email and code', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordCodePageComponent], + providers: [ + provideRouter([]), + ...authProviders(), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ email: 'ada@example.com' }), + }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.form.setValue({ + digit1: '1', + digit2: '2', + digit3: '3', + digit4: '4', + }); + component.onSubmit(); + + expect(validatePasswordResetCode).toHaveBeenCalledWith('ada@example.com', '1234'); + expect(navigateSpy).toHaveBeenCalledWith(['/recuperar-contrasena/restablecer'], { + queryParams: { + email: 'ada@example.com', + code: '1234', + }, + }); + }); + + it('shows the validation error and stays on the code page', async () => { + validatePasswordResetCode.mockReturnValue(throwError(() => ({ + error: { + errors: { + codigo: ['El código ingresado es inválido.'], + }, + }, + }))); + + await TestBed.configureTestingModule({ + imports: [RecoverPasswordCodePageComponent], + providers: [ + provideRouter([]), + ...authProviders(), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ email: 'ada@example.com' }), + }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.form.setValue({ + digit1: '9', + digit2: '9', + digit3: '9', + digit4: '9', + }); + component.onSubmit(); + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(component.serverError()).toBe('El código ingresado es inválido.'); + expect(showDangerToast).toHaveBeenCalledWith('El código ingresado es inválido.'); + }); + + it('requests a new code and clears the previous digits', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordCodePageComponent], + providers: [ + provideRouter([]), + ...authProviders(), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ email: 'ada@example.com' }), + }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent); + const component = fixture.componentInstance as any; + fixture.detectChanges(); + component.form.setValue({ + digit1: '1', + digit2: '2', + digit3: '3', + digit4: '4', + }); + + component.resendCode(); + + expect(requestPasswordReset).toHaveBeenCalledWith('ada@example.com'); + expect(component.form.getRawValue()).toEqual({ + digit1: '', + digit2: '', + digit3: '', + digit4: '', + }); + expect(showSuccessToast).toHaveBeenCalledWith('Código reenviado correctamente.'); + }); +}); diff --git a/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.ts b/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.ts new file mode 100644 index 0000000..9cb199a --- /dev/null +++ b/src/app/features/store/pages/recover-password-code-page/recover-password-code-page.component.ts @@ -0,0 +1,191 @@ +import { ChangeDetectionStrategy, Component, inject, signal, viewChildren } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { ButtonComponent } from '../../../../shared/components/button/button.component'; +import { InputComponent } from '../../../../shared/components/input/input.component'; + +type CodeControlName = 'digit1' | 'digit2' | 'digit3' | 'digit4'; +const CODE_CONTROL_NAMES: CodeControlName[] = ['digit1', 'digit2', 'digit3', 'digit4']; + +@Component({ + selector: 'app-recover-password-code-page', + imports: [ReactiveFormsModule, InputComponent, ButtonComponent], + templateUrl: './recover-password-code-page.component.html', + styleUrl: './recover-password-code-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RecoverPasswordCodePageComponent { + private readonly formBuilder = inject(FormBuilder); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly toastService = inject(ToastService); + private readonly submittedState = signal(false); + private readonly isSubmittingState = signal(false); + private readonly isResendingState = signal(false); + private readonly serverErrorState = signal(null); + private readonly digitInputs = viewChildren(InputComponent); + + protected readonly email = this.route.snapshot.queryParamMap.get('email') ?? ''; + protected readonly form = this.formBuilder.nonNullable.group({ + digit1: ['', [Validators.required, Validators.pattern(/^\d$/)]], + digit2: ['', [Validators.required, Validators.pattern(/^\d$/)]], + digit3: ['', [Validators.required, Validators.pattern(/^\d$/)]], + digit4: ['', [Validators.required, Validators.pattern(/^\d$/)]], + }); + protected readonly submitted = this.submittedState.asReadonly(); + protected readonly isSubmitting = this.isSubmittingState.asReadonly(); + protected readonly isResending = this.isResendingState.asReadonly(); + protected readonly serverError = this.serverErrorState.asReadonly(); + + protected updateDigit(controlName: CodeControlName, value: string | number): void { + const digit = String(value).replace(/\D/g, '').slice(-1); + this.form.controls[controlName].setValue(digit); + + const currentIndex = CODE_CONTROL_NAMES.indexOf(controlName); + if (digit && currentIndex < CODE_CONTROL_NAMES.length - 1) { + this.digitInputs()[currentIndex + 1]?.focus(); + } + } + + protected pasteCode(event: ClipboardEvent): void { + event.preventDefault(); + + const pastedText = event.clipboardData?.getData('text') ?? ''; + const digits = pastedText.replace(/\D/g, ''); + + if (digits.length !== CODE_CONTROL_NAMES.length) { + console.warn('Password reset code paste was rejected because it did not contain four digits.', { + digitCount: digits.length, + }); + this.showServerError('Pegá un código de cuatro dígitos.'); + return; + } + + CODE_CONTROL_NAMES.forEach((controlName, index) => { + this.form.controls[controlName].setValue(digits[index]); + }); + + this.submittedState.set(false); + this.serverErrorState.set(null); + this.digitInputs()[CODE_CONTROL_NAMES.length - 1]?.focus(); + } + + protected isCodeInvalid(): boolean { + return this.form.invalid && this.submitted(); + } + + protected onSubmit(): void { + this.submittedState.set(true); + this.serverErrorState.set(null); + + if (this.form.invalid || this.isSubmitting()) { + this.form.markAllAsTouched(); + return; + } + + const code = Object.values(this.form.getRawValue()).join(''); + + if (!this.email) { + console.warn('Password reset code validation cannot start without an email.'); + this.showServerError('No se pudo identificar la solicitud de recuperación.'); + return; + } + + this.isSubmittingState.set(true); + + this.authService.validatePasswordResetCode(this.email, code).subscribe({ + next: (response) => { + this.isSubmittingState.set(false); + + if (response.status !== 200 || response.body?.status !== 'validated') { + console.warn('Password reset code validation returned an unexpected status.', { + httpStatus: response.status, + attemptStatus: response.body?.status ?? null, + }); + this.showServerError('No se pudo validar el código. Intenta nuevamente.'); + return; + } + + void this.router.navigate(['/recuperar-contrasena/restablecer'], { + queryParams: { + email: this.email, + code, + }, + }); + }, + error: (error: unknown) => { + this.isSubmittingState.set(false); + console.error('Password reset code validation request failed.', error); + this.showServerError(this.resolveErrorMessage(error)); + }, + }); + } + + protected resendCode(): void { + if (!this.email || this.isResending()) { + return; + } + + this.serverErrorState.set(null); + this.isResendingState.set(true); + + this.authService.requestPasswordReset(this.email).subscribe({ + next: (response) => { + this.isResendingState.set(false); + + if (response.status !== 202 || response.body?.status !== 'pending') { + console.warn('Password reset code resend returned an unexpected status.', { + httpStatus: response.status, + attemptStatus: response.body?.status ?? null, + }); + this.showServerError('No se pudo reenviar el código. Intenta nuevamente.'); + return; + } + + this.submittedState.set(false); + this.form.reset(); + this.digitInputs()[0]?.focus(); + this.toastService.success('Código reenviado correctamente.'); + }, + error: (error: unknown) => { + this.isResendingState.set(false); + console.error('Password reset code resend request failed.', error); + this.showServerError(this.resolveErrorMessage(error)); + }, + }); + } + + protected goBack(): void { + void this.router.navigate(['/recuperar-contrasena'], { + queryParams: this.email ? { email: this.email } : undefined, + }); + } + + private showServerError(message: string): void { + this.serverErrorState.set(message); + this.toastService.danger(message); + } + + private resolveErrorMessage(error: unknown): string { + const errorPayload = + typeof error === 'object' && error !== null && 'error' in error + ? (error as { error?: { errors?: Record; message?: string } }).error + : undefined; + const codeMessages = errorPayload?.errors?.['codigo']; + const codeError = Array.isArray(codeMessages) ? codeMessages[0] : null; + + if (typeof codeError === 'string' && codeError.trim()) { + return codeError; + } + + if (typeof errorPayload?.message === 'string' && errorPayload.message.trim()) { + return errorPayload.message; + } + + return 'No se pudo validar el código. Intenta nuevamente.'; + } +} diff --git a/src/app/features/store/pages/recover-password-page/recover-password-page.component.html b/src/app/features/store/pages/recover-password-page/recover-password-page.component.html new file mode 100644 index 0000000..df59169 --- /dev/null +++ b/src/app/features/store/pages/recover-password-page/recover-password-page.component.html @@ -0,0 +1,53 @@ +
+

Recuperar contraseña

+

+ Ingresá tu Email para restablecer tu Contraseña. +

+
+ +
+
+ + + @if (getEmailError(); as errorMessage) { + {{ errorMessage }} + } + @if (serverError(); as errorMessage) { + {{ errorMessage }} + } +
+ +
+ + {{ isSubmitting() ? 'Enviando...' : 'Recuperar acceso' }} + + + + Volver + +
+
diff --git a/src/app/features/store/pages/recover-password-page/recover-password-page.component.scss b/src/app/features/store/pages/recover-password-page/recover-password-page.component.scss new file mode 100644 index 0000000..7e9a8b4 --- /dev/null +++ b/src/app/features/store/pages/recover-password-page/recover-password-page.component.scss @@ -0,0 +1,17 @@ +:host { + display: block; +} + +.recover-password-page__description { + color: #a0a0a0; + font-size: 12px; + font-weight: 325; +} + +.recover-password-page__form { + width: 100%; +} + +.recover-password-page__back-action { + min-height: 40px; +} diff --git a/src/app/features/store/pages/recover-password-page/recover-password-page.component.spec.ts b/src/app/features/store/pages/recover-password-page/recover-password-page.component.spec.ts new file mode 100644 index 0000000..0d73b3a --- /dev/null +++ b/src/app/features/store/pages/recover-password-page/recover-password-page.component.spec.ts @@ -0,0 +1,139 @@ +import { HttpResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { RecoverPasswordPageComponent } from './recover-password-page.component'; + +describe('RecoverPasswordPageComponent', () => { + let requestPasswordReset: ReturnType; + let showDangerToast: ReturnType; + + beforeEach(() => { + TestBed.resetTestingModule(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + requestPasswordReset = vi.fn(() => of(new HttpResponse({ + body: { + message: 'Solicitud aceptada.', + status: 'pending' as const + }, + status: 202 + }))); + showDangerToast = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function authProviders() { + return [ + { provide: AuthService, useValue: { requestPasswordReset } }, + { provide: ToastService, useValue: { danger: showDangerToast } } + ]; + } + + it('validates the email before submitting', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordPageComponent], + providers: [provideRouter([]), ...authProviders()], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordPageComponent); + const component = fixture.componentInstance as any; + + component.form.controls.email.setValue('invalid-email'); + component.onSubmit(); + + expect(component.form.invalid).toBe(true); + expect(component.getEmailError()).toBe('Ingresa un email válido.'); + }); + + it('navigates back to login', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordPageComponent], + providers: [provideRouter([]), ...authProviders()], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordPageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.goToLogin(); + + expect(navigateSpy).toHaveBeenCalledWith(['/login']); + }); + + it('continues to the code page with the entered email', async () => { + await TestBed.configureTestingModule({ + imports: [RecoverPasswordPageComponent], + providers: [provideRouter([]), ...authProviders()], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordPageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.form.controls.email.setValue('ada@example.com'); + component.onSubmit(); + + expect(requestPasswordReset).toHaveBeenCalledWith('ada@example.com'); + expect(navigateSpy).toHaveBeenCalledWith(['/recuperar-contrasena/codigo'], { + queryParams: { email: 'ada@example.com' }, + }); + }); + + it('stays on the page when the endpoint does not finish as pending', async () => { + requestPasswordReset.mockReturnValue(of(new HttpResponse({ + body: null, + status: 200 + }))); + + await TestBed.configureTestingModule({ + imports: [RecoverPasswordPageComponent], + providers: [provideRouter([]), ...authProviders()], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordPageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.form.controls.email.setValue('ada@example.com'); + component.onSubmit(); + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(component.serverError()).toBe( + 'No se pudo iniciar la recuperación. Intenta nuevamente.', + ); + expect(showDangerToast).toHaveBeenCalled(); + }); + + it('shows the API error and does not navigate when the request fails', async () => { + requestPasswordReset.mockReturnValue(throwError(() => ({ + error: { message: 'Servicio temporalmente no disponible.' } + }))); + + await TestBed.configureTestingModule({ + imports: [RecoverPasswordPageComponent], + providers: [provideRouter([]), ...authProviders()], + }).compileComponents(); + + const fixture = TestBed.createComponent(RecoverPasswordPageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.form.controls.email.setValue('ada@example.com'); + component.onSubmit(); + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(component.serverError()).toBe('Servicio temporalmente no disponible.'); + expect(showDangerToast).toHaveBeenCalledWith('Servicio temporalmente no disponible.'); + }); +}); diff --git a/src/app/features/store/pages/recover-password-page/recover-password-page.component.ts b/src/app/features/store/pages/recover-password-page/recover-password-page.component.ts new file mode 100644 index 0000000..a6281fb --- /dev/null +++ b/src/app/features/store/pages/recover-password-page/recover-password-page.component.ts @@ -0,0 +1,135 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { ButtonComponent } from '../../../../shared/components/button/button.component'; +import { InputComponent } from '../../../../shared/components/input/input.component'; + +const EMAIL_MAX_LENGTH = 255; + +@Component({ + selector: 'app-recover-password-page', + imports: [ReactiveFormsModule, InputComponent, ButtonComponent], + templateUrl: './recover-password-page.component.html', + styleUrl: './recover-password-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RecoverPasswordPageComponent { + private readonly formBuilder = inject(FormBuilder); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly toastService = inject(ToastService); + private readonly submittedState = signal(false); + private readonly isSubmittingState = signal(false); + private readonly serverErrorState = signal(null); + + protected readonly form = this.formBuilder.nonNullable.group({ + email: [ + this.route.snapshot.queryParamMap.get('email') ?? '', + [Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)], + ], + }); + protected readonly submitted = this.submittedState.asReadonly(); + protected readonly isSubmitting = this.isSubmittingState.asReadonly(); + protected readonly serverError = this.serverErrorState.asReadonly(); + + protected updateEmail(value: string | number): void { + this.form.controls.email.setValue(String(value)); + } + + protected showEmailError(): boolean { + const email = this.form.controls.email; + + return email.invalid && (email.touched || this.submitted()); + } + + protected getEmailError(): string | null { + const email = this.form.controls.email; + + if (!this.showEmailError()) { + return null; + } + + if (email.hasError('required')) { + return 'Este campo es obligatorio.'; + } + + if (email.hasError('maxlength')) { + return `No puede superar los ${EMAIL_MAX_LENGTH} caracteres.`; + } + + if (email.hasError('email')) { + return 'Ingresa un email válido.'; + } + + return 'El valor ingresado no es válido.'; + } + + protected onSubmit(): void { + this.submittedState.set(true); + this.serverErrorState.set(null); + + if (this.form.invalid || this.isSubmitting()) { + this.form.markAllAsTouched(); + return; + } + + const email = this.form.controls.email.value; + this.isSubmittingState.set(true); + + this.authService.requestPasswordReset(email).subscribe({ + next: (response) => { + this.isSubmittingState.set(false); + + if (response.status !== 202 || response.body?.status !== 'pending') { + console.warn('Password reset attempt returned an unexpected status.', { + httpStatus: response.status, + attemptStatus: response.body?.status ?? null, + }); + this.showServerError('No se pudo iniciar la recuperación. Intenta nuevamente.'); + return; + } + + void this.router.navigate(['/recuperar-contrasena/codigo'], { + queryParams: { email }, + }); + }, + error: (error: unknown) => { + this.isSubmittingState.set(false); + console.error('Password reset attempt request failed.', error); + this.showServerError(this.resolveErrorMessage(error)); + }, + }); + } + + protected goToLogin(): void { + void this.router.navigate(['/login']); + } + + private showServerError(message: string): void { + this.serverErrorState.set(message); + this.toastService.danger(message); + } + + private resolveErrorMessage(error: unknown): string { + const errorPayload = + typeof error === 'object' && error !== null && 'error' in error + ? (error as { error?: { errors?: Record; message?: string } }).error + : undefined; + const emailMessages = errorPayload?.errors?.['email']; + const emailError = Array.isArray(emailMessages) ? emailMessages[0] : null; + + if (typeof emailError === 'string' && emailError.trim()) { + return emailError; + } + + if (typeof errorPayload?.message === 'string' && errorPayload.message.trim()) { + return errorPayload.message; + } + + return 'No se pudo iniciar la recuperación. Intenta nuevamente.'; + } +} diff --git a/src/app/features/store/pages/reset-password-page/reset-password-page.component.html b/src/app/features/store/pages/reset-password-page/reset-password-page.component.html new file mode 100644 index 0000000..582aaf6 --- /dev/null +++ b/src/app/features/store/pages/reset-password-page/reset-password-page.component.html @@ -0,0 +1,56 @@ +
+

Restablecer contraseña

+
+ +
+
+ + + @if (getControlError('password'); as errorMessage) { + {{ errorMessage }} + } +
+ +
+ + + @if (getControlError('password_confirmation'); as errorMessage) { + {{ errorMessage }} + } +
+ + @if (serverError(); as errorMessage) { + {{ errorMessage }} + } + + + {{ isSubmitting() ? 'Guardando...' : 'Guardar' }} + +
diff --git a/src/app/features/store/pages/reset-password-page/reset-password-page.component.scss b/src/app/features/store/pages/reset-password-page/reset-password-page.component.scss new file mode 100644 index 0000000..158ef27 --- /dev/null +++ b/src/app/features/store/pages/reset-password-page/reset-password-page.component.scss @@ -0,0 +1,7 @@ +:host { + display: block; +} + +.reset-password-page__form { + width: 100%; +} diff --git a/src/app/features/store/pages/reset-password-page/reset-password-page.component.spec.ts b/src/app/features/store/pages/reset-password-page/reset-password-page.component.spec.ts new file mode 100644 index 0000000..fec20c9 --- /dev/null +++ b/src/app/features/store/pages/reset-password-page/reset-password-page.component.spec.ts @@ -0,0 +1,148 @@ +import { HttpResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ModalService } from '../../../../core/services/modal.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { ResetPasswordPageComponent } from './reset-password-page.component'; + +describe('ResetPasswordPageComponent', () => { + let resetPassword: ReturnType; + let showDangerToast: ReturnType; + + beforeEach(() => { + TestBed.resetTestingModule(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + resetPassword = vi.fn(() => of(new HttpResponse({ + body: { + message: 'Contraseña modificada correctamente.', + status: 'used' as const, + }, + status: 200, + }))); + showDangerToast = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function resetProviders(modalService: { openSimple: ReturnType }) { + return [ + provideRouter([]), + { provide: AuthService, useValue: { resetPassword } }, + { provide: ModalService, useValue: modalService }, + { provide: ToastService, useValue: { danger: showDangerToast } }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ + email: 'ada@example.com', + code: '1234', + }), + }, + }, + }, + ]; + } + + it('rejects passwords that do not match', async () => { + const modalService = { + openSimple: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ResetPasswordPageComponent], + providers: resetProviders(modalService), + }).compileComponents(); + + const fixture = TestBed.createComponent(ResetPasswordPageComponent); + const component = fixture.componentInstance as any; + + component.form.setValue({ + password: 'Secret!123', + password_confirmation: 'Different!123', + }); + component.onSubmit(); + + expect(component.form.hasError('passwordMismatch')).toBe(true); + expect(component.getControlError('password_confirmation')).toBe( + 'Las contraseñas no coinciden.', + ); + expect(modalService.openSimple).not.toHaveBeenCalled(); + expect(resetPassword).not.toHaveBeenCalled(); + }); + + it('shows the success modal and returns to login for matching passwords', async () => { + const modalService = { + openSimple: vi.fn().mockReturnValue(of(undefined)), + }; + + await TestBed.configureTestingModule({ + imports: [ResetPasswordPageComponent], + providers: resetProviders(modalService), + }).compileComponents(); + + const fixture = TestBed.createComponent(ResetPasswordPageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + component.form.setValue({ + password: 'Secret!123', + password_confirmation: 'Secret!123', + }); + component.onSubmit(); + + expect(component.form.valid).toBe(true); + expect(component.getControlError('password_confirmation')).toBeNull(); + expect(resetPassword).toHaveBeenCalledWith({ + email: 'ada@example.com', + codigo: '1234', + password: 'Secret!123', + password_confirmation: 'Secret!123', + }); + expect(modalService.openSimple).toHaveBeenCalledWith({ + content: 'Contraseña modificada correctamente', + buttonLabel: 'Cerrar', + }); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); + }); + + it('shows the API error without opening the success modal', async () => { + resetPassword.mockReturnValue(throwError(() => ({ + error: { + errors: { + codigo: ['La solicitud de recuperación es inválida o ya fue utilizada.'], + }, + }, + }))); + const modalService = { + openSimple: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ResetPasswordPageComponent], + providers: resetProviders(modalService), + }).compileComponents(); + + const fixture = TestBed.createComponent(ResetPasswordPageComponent); + const component = fixture.componentInstance as any; + + component.form.setValue({ + password: 'Secret!123', + password_confirmation: 'Secret!123', + }); + component.onSubmit(); + + expect(modalService.openSimple).not.toHaveBeenCalled(); + expect(component.serverError()).toBe( + 'La solicitud de recuperación es inválida o ya fue utilizada.', + ); + expect(showDangerToast).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/store/pages/reset-password-page/reset-password-page.component.ts b/src/app/features/store/pages/reset-password-page/reset-password-page.component.ts new file mode 100644 index 0000000..eb1fb04 --- /dev/null +++ b/src/app/features/store/pages/reset-password-page/reset-password-page.component.ts @@ -0,0 +1,193 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { + AbstractControl, + FormBuilder, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ModalService } from '../../../../core/services/modal.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { ButtonComponent } from '../../../../shared/components/button/button.component'; +import { InputComponent } from '../../../../shared/components/input/input.component'; + +const PASSWORD_MIN_LENGTH = 8; + +const passwordsMatchValidator: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const password = control.get('password')?.value; + const passwordConfirmation = control.get('password_confirmation')?.value; + + if (!password || !passwordConfirmation) { + return null; + } + + return password === passwordConfirmation ? null : { passwordMismatch: true }; +}; + +type PasswordControlName = 'password' | 'password_confirmation'; + +@Component({ + selector: 'app-reset-password-page', + imports: [ReactiveFormsModule, InputComponent, ButtonComponent], + templateUrl: './reset-password-page.component.html', + styleUrl: './reset-password-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResetPasswordPageComponent { + private readonly formBuilder = inject(FormBuilder); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly modalService = inject(ModalService); + private readonly toastService = inject(ToastService); + private readonly submittedState = signal(false); + private readonly isSubmittingState = signal(false); + private readonly serverErrorState = signal(null); + + private readonly email = this.route.snapshot.queryParamMap.get('email') ?? ''; + private readonly code = this.route.snapshot.queryParamMap.get('code') ?? ''; + + protected readonly form = this.formBuilder.nonNullable.group( + { + password: [ + '', + [ + Validators.required, + Validators.minLength(PASSWORD_MIN_LENGTH), + Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).+$/), + ], + ], + password_confirmation: ['', [Validators.required]], + }, + { + validators: [passwordsMatchValidator], + }, + ); + protected readonly submitted = this.submittedState.asReadonly(); + protected readonly isSubmitting = this.isSubmittingState.asReadonly(); + protected readonly serverError = this.serverErrorState.asReadonly(); + + protected updatePassword(controlName: PasswordControlName, value: string | number): void { + this.form.controls[controlName].setValue(String(value)); + } + + protected showControlError(controlName: PasswordControlName): boolean { + const control = this.form.controls[controlName]; + const hasPasswordMismatch = + controlName === 'password_confirmation' && + this.form.hasError('passwordMismatch') && + (control.touched || this.submitted()); + + return (control.invalid && (control.touched || this.submitted())) || hasPasswordMismatch; + } + + protected getControlError(controlName: PasswordControlName): string | null { + const control = this.form.controls[controlName]; + + if (!this.showControlError(controlName)) { + return null; + } + + if (control.hasError('required')) { + return 'Este campo es obligatorio.'; + } + + if (control.hasError('minlength')) { + return `Debe tener al menos ${PASSWORD_MIN_LENGTH} caracteres.`; + } + + if (controlName === 'password' && control.hasError('pattern')) { + return 'Debe contener mayúscula, minúscula y un carácter especial.'; + } + + if (controlName === 'password_confirmation' && this.form.hasError('passwordMismatch')) { + return 'Las contraseñas no coinciden.'; + } + + return 'El valor ingresado no es válido.'; + } + + protected onSubmit(): void { + this.submittedState.set(true); + this.serverErrorState.set(null); + + if (this.form.invalid || this.isSubmitting()) { + this.form.markAllAsTouched(); + return; + } + + if (!this.email || !/^\d{4}$/.test(this.code)) { + console.warn('Password reset cannot start without a validated recovery request.'); + this.showServerError('La solicitud de recuperación es inválida.'); + return; + } + + this.isSubmittingState.set(true); + + this.authService.resetPassword({ + email: this.email, + codigo: this.code, + ...this.form.getRawValue(), + }).subscribe({ + next: (response) => { + this.isSubmittingState.set(false); + + if (response.status !== 200 || response.body?.status !== 'used') { + console.warn('Password reset returned an unexpected status.', { + httpStatus: response.status, + attemptStatus: response.body?.status ?? null, + }); + this.showServerError('No se pudo modificar la contraseña. Intenta nuevamente.'); + return; + } + + this.modalService + .openSimple({ + content: 'Contraseña modificada correctamente', + buttonLabel: 'Cerrar', + }) + .subscribe(() => { + void this.router.navigate(['/login']); + }); + }, + error: (error: unknown) => { + this.isSubmittingState.set(false); + console.error('Password reset request failed.', error); + this.showServerError(this.resolveErrorMessage(error)); + }, + }); + } + + private showServerError(message: string): void { + this.serverErrorState.set(message); + this.toastService.danger(message); + } + + private resolveErrorMessage(error: unknown): string { + const errorPayload = + typeof error === 'object' && error !== null && 'error' in error + ? (error as { error?: { errors?: Record; message?: string } }).error + : undefined; + const errors = errorPayload?.errors; + + for (const field of ['codigo', 'password'] as const) { + const messages = errors?.[field]; + + if (Array.isArray(messages) && typeof messages[0] === 'string' && messages[0].trim()) { + return messages[0]; + } + } + + if (typeof errorPayload?.message === 'string' && errorPayload.message.trim()) { + return errorPayload.message; + } + + return 'No se pudo modificar la contraseña. Intenta nuevamente.'; + } +} diff --git a/src/app/features/store/store.routes.ts b/src/app/features/store/store.routes.ts index eb9a2be..f360335 100644 --- a/src/app/features/store/store.routes.ts +++ b/src/app/features/store/store.routes.ts @@ -6,6 +6,9 @@ import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards' import { LoginPageComponent } from './pages/login-page/login-page.component'; import { hasMenuGuard } from '../../core/guards/menu.guard'; import { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver'; +import { RecoverPasswordCodePageComponent } from './pages/recover-password-code-page/recover-password-code-page.component'; +import { RecoverPasswordPageComponent } from './pages/recover-password-page/recover-password-page.component'; +import { ResetPasswordPageComponent } from './pages/reset-password-page/reset-password-page.component'; import { RegisterPageComponent } from './pages/register-page/register-page.component'; import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component'; import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver'; @@ -45,6 +48,25 @@ export const routes: Routes = [ }, ], }, + { + path: 'recuperar-contrasena', + canActivate: [guestOnlyGuard], + component: SimpleLayoutComponent, + children: [ + { + path: '', + component: RecoverPasswordPageComponent, + }, + { + path: 'codigo', + component: RecoverPasswordCodePageComponent, + }, + { + path: 'restablecer', + component: ResetPasswordPageComponent, + }, + ], + }, { path: 'buscar', loadComponent: () => diff --git a/src/app/shared/components/input/input.component.html b/src/app/shared/components/input/input.component.html index 5066af2..5895e7c 100644 --- a/src/app/shared/components/input/input.component.html +++ b/src/app/shared/components/input/input.component.html @@ -24,6 +24,7 @@ [placeholder]="placeholder()" [value]="type() !== 'file' ? value() : null" (input)="type() !== 'file' ? onValueChange($event) : null" + (paste)="onPaste($event)" (change)="type() === 'file' ? onFileChange($event) : null" [attr.maxlength]="type() !== 'file' ? maxlength() : null" [attr.accept]="type() === 'file' ? accept() : null" diff --git a/src/app/shared/components/input/input.component.ts b/src/app/shared/components/input/input.component.ts index 25437dc..6df1333 100644 --- a/src/app/shared/components/input/input.component.ts +++ b/src/app/shared/components/input/input.component.ts @@ -85,6 +85,7 @@ export class InputComponent { readonly disabledChange = output(); readonly fileChange = output(); + readonly pasteEvent = output(); readonly visibleChange = output(); protected readonly inputType = computed(() => { @@ -166,6 +167,10 @@ export class InputComponent { }); } + focus(): void { + this.inputElement()?.nativeElement.focus(); + } + protected onValueChange(event: Event): void { this.value.set((event.target as HTMLInputElement).value); } @@ -176,6 +181,10 @@ export class InputComponent { this.fileChange.emit(file); } + protected onPaste(event: ClipboardEvent): void { + this.pasteEvent.emit(event); + } + protected toggleEditableState(): void { if (this.type() !== 'editable') { return;