Forgot password flow
This commit is contained in:
@@ -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 () => {
|
it('redirects authenticated users away from /login', async () => {
|
||||||
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
|
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,25 @@ export interface RegisterResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
data: AuthUser;
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,6 +174,123 @@ describe('AuthService', () => {
|
|||||||
httpController.verify();
|
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', () => {
|
it('propagates logout errors without clearing the local session', () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
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 { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
||||||
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
|
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
|
||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
LoginResponse,
|
LoginResponse,
|
||||||
RegisterPayload,
|
RegisterPayload,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
UpdateProfilePayload
|
ResetPasswordPayload,
|
||||||
|
ResetPasswordAttemptResponse,
|
||||||
|
ResetPasswordResponse,
|
||||||
|
UpdateProfilePayload,
|
||||||
|
ValidateResetPasswordAttemptResponse
|
||||||
} from './auth.interfaces';
|
} from './auth.interfaces';
|
||||||
import { CookieService } from '../cookie/cookie.service';
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
import { TenantService } from '../tenant.service';
|
import { TenantService } from '../tenant.service';
|
||||||
@@ -53,6 +57,42 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestPasswordReset(
|
||||||
|
email: string
|
||||||
|
): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
|
||||||
|
const tenantCode = this.tenantService.getTenant()?.codigo;
|
||||||
|
|
||||||
|
return this.http.post<ResetPasswordAttemptResponse>(
|
||||||
|
`${environment.url}password/reset-attempts`,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
...(tenantCode ? { tenant_codigo: tenantCode } : {})
|
||||||
|
},
|
||||||
|
{ observe: 'response' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePasswordResetCode(
|
||||||
|
email: string,
|
||||||
|
codigo: string
|
||||||
|
): Observable<HttpResponse<ValidateResetPasswordAttemptResponse>> {
|
||||||
|
return this.http.post<ValidateResetPasswordAttemptResponse>(
|
||||||
|
`${environment.url}password/reset-attempts/validate`,
|
||||||
|
{ email, codigo },
|
||||||
|
{ observe: 'response' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPassword(
|
||||||
|
payload: ResetPasswordPayload
|
||||||
|
): Observable<HttpResponse<ResetPasswordResponse>> {
|
||||||
|
return this.http.post<ResetPasswordResponse>(
|
||||||
|
`${environment.url}password/reset`,
|
||||||
|
payload,
|
||||||
|
{ observe: 'response' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
loginWithGoogle(): void {
|
loginWithGoogle(): void {
|
||||||
const tenant = this.tenantService.getTenant();
|
const tenant = this.tenantService.getTenant();
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
|
|||||||
@@ -40,7 +40,11 @@
|
|||||||
<small class="text-danger">{{ errorMessage }}</small>
|
<small class="text-danger">{{ errorMessage }}</small>
|
||||||
}
|
}
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<button type="button" class="login-page__forgot-password btn btn-link p-0 border-0 text-decoration-none">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="login-page__forgot-password btn btn-link p-0 border-0 text-decoration-none"
|
||||||
|
(click)="goToRecoverPassword()"
|
||||||
|
>
|
||||||
Olvide mi contraseña
|
Olvide mi contraseña
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +60,7 @@
|
|||||||
>
|
>
|
||||||
{{ isSubmitting() ? 'Ingresando...' : 'Ingresar' }}
|
{{ isSubmitting() ? 'Ingresando...' : 'Ingresar' }}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
<app-button
|
<app-button
|
||||||
type="button"
|
type="button"
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export class LoginPageComponent {
|
|||||||
void this.router.navigate(['/register']);
|
void this.router.navigate(['/register']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goToRecoverPassword(): void {
|
||||||
|
void this.router.navigate(['/recuperar-contrasena']);
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
this.submittedState.set(true);
|
this.submittedState.set(true);
|
||||||
this.serverErrorState.set(null);
|
this.serverErrorState.set(null);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<header class="recover-password-code-page__header text-center mb-4">
|
||||||
|
<h1 id="recover-password-code-title" class="auth-title mb-4">Ingresá el código</h1>
|
||||||
|
<p class="recover-password-code-page__description mb-0">
|
||||||
|
Te enviamos un código a <strong>{{ email }}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="recover-password-code-page__description fst-italic mb-0">
|
||||||
|
Recordá revisar en Correo no deseado.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="recover-password-code-page__form d-grid gap-4"
|
||||||
|
novalidate
|
||||||
|
aria-labelledby="recover-password-code-title"
|
||||||
|
[formGroup]="form"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<fieldset class="border-0 p-0 m-0">
|
||||||
|
<legend class="visually-hidden">Código de recuperación de cuatro dígitos</legend>
|
||||||
|
<div class="recover-password-code-page__inputs d-flex justify-content-center gap-2">
|
||||||
|
<app-input
|
||||||
|
id="recover-password-code-digit-1"
|
||||||
|
type="text"
|
||||||
|
[maxlength]="1"
|
||||||
|
[value]="form.controls.digit1.value"
|
||||||
|
[invalid]="isCodeInvalid()"
|
||||||
|
(valueChange)="updateDigit('digit1', $event)"
|
||||||
|
(pasteEvent)="pasteCode($event)"
|
||||||
|
/>
|
||||||
|
<app-input
|
||||||
|
id="recover-password-code-digit-2"
|
||||||
|
type="text"
|
||||||
|
[maxlength]="1"
|
||||||
|
[value]="form.controls.digit2.value"
|
||||||
|
[invalid]="isCodeInvalid()"
|
||||||
|
(valueChange)="updateDigit('digit2', $event)"
|
||||||
|
(pasteEvent)="pasteCode($event)"
|
||||||
|
/>
|
||||||
|
<app-input
|
||||||
|
id="recover-password-code-digit-3"
|
||||||
|
type="text"
|
||||||
|
[maxlength]="1"
|
||||||
|
[value]="form.controls.digit3.value"
|
||||||
|
[invalid]="isCodeInvalid()"
|
||||||
|
(valueChange)="updateDigit('digit3', $event)"
|
||||||
|
(pasteEvent)="pasteCode($event)"
|
||||||
|
/>
|
||||||
|
<app-input
|
||||||
|
id="recover-password-code-digit-4"
|
||||||
|
type="text"
|
||||||
|
[maxlength]="1"
|
||||||
|
[value]="form.controls.digit4.value"
|
||||||
|
[invalid]="isCodeInvalid()"
|
||||||
|
(valueChange)="updateDigit('digit4', $event)"
|
||||||
|
(pasteEvent)="pasteCode($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
@if (isCodeInvalid()) {
|
||||||
|
<small class="text-danger d-block text-center mt-2">
|
||||||
|
Ingresá los cuatro dígitos del código.
|
||||||
|
</small>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (serverError(); as errorMessage) {
|
||||||
|
<small class="text-danger d-block text-center mt-2" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</small>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<app-button
|
||||||
|
type="button"
|
||||||
|
variant="borderless"
|
||||||
|
buttonClass="recover-password-code-page__resend-action px-3 py-2"
|
||||||
|
[disabled]="isResending()"
|
||||||
|
(click)="resendCode()"
|
||||||
|
>
|
||||||
|
{{ isResending() ? 'Reenviando...' : 'Reenviar Código' }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 text-center">
|
||||||
|
<app-button
|
||||||
|
type="submit"
|
||||||
|
hostClass="w-100 d-block"
|
||||||
|
buttonClass="w-100"
|
||||||
|
[disabled]="isSubmitting()"
|
||||||
|
>
|
||||||
|
{{ isSubmitting() ? 'Validando...' : 'Validar' }}
|
||||||
|
</app-button>
|
||||||
|
|
||||||
|
<app-button
|
||||||
|
type="button"
|
||||||
|
variant="borderless"
|
||||||
|
hostClass="w-100 d-block text-center"
|
||||||
|
buttonClass="recover-password-code-page__back-action px-3 py-2"
|
||||||
|
(click)="goBack()"
|
||||||
|
>
|
||||||
|
Volver
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
let requestPasswordReset: ReturnType<typeof vi.fn>;
|
||||||
|
let showDangerToast: ReturnType<typeof vi.fn>;
|
||||||
|
let showSuccessToast: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(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<string, string[]>; 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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<header class="recover-password-page__header text-center mb-4">
|
||||||
|
<h1 id="recover-password-title" class="auth-title mb-3">Recuperar contraseña</h1>
|
||||||
|
<p class="recover-password-page__description mb-0">
|
||||||
|
Ingresá tu Email para restablecer tu Contraseña.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="recover-password-page__form d-grid gap-3"
|
||||||
|
novalidate
|
||||||
|
aria-labelledby="recover-password-title"
|
||||||
|
[formGroup]="form"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
<label class="visually-hidden" for="recover-password-email">Email</label>
|
||||||
|
<app-input
|
||||||
|
id="recover-password-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
[value]="form.controls.email.value"
|
||||||
|
[invalid]="showEmailError()"
|
||||||
|
(valueChange)="updateEmail($event)"
|
||||||
|
/>
|
||||||
|
@if (getEmailError(); as errorMessage) {
|
||||||
|
<small class="text-danger">{{ errorMessage }}</small>
|
||||||
|
}
|
||||||
|
@if (serverError(); as errorMessage) {
|
||||||
|
<small class="text-danger" role="alert">{{ errorMessage }}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 text-center">
|
||||||
|
<app-button
|
||||||
|
type="submit"
|
||||||
|
hostClass="w-100 d-block"
|
||||||
|
buttonClass="w-100"
|
||||||
|
[disabled]="isSubmitting()"
|
||||||
|
>
|
||||||
|
{{ isSubmitting() ? 'Enviando...' : 'Recuperar acceso' }}
|
||||||
|
</app-button>
|
||||||
|
|
||||||
|
<app-button
|
||||||
|
type="button"
|
||||||
|
variant="borderless"
|
||||||
|
hostClass="w-100 d-block text-center"
|
||||||
|
buttonClass="recover-password-page__back-action px-3 py-2"
|
||||||
|
(click)="goToLogin()"
|
||||||
|
>
|
||||||
|
Volver
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
let showDangerToast: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(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<string, string[]>; 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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<header class="reset-password-page__header text-center mb-4">
|
||||||
|
<h1 id="reset-password-title" class="auth-title">Restablecer contraseña</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="reset-password-page__form d-grid gap-3"
|
||||||
|
novalidate
|
||||||
|
aria-labelledby="reset-password-title"
|
||||||
|
[formGroup]="form"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
<label class="visually-hidden" for="reset-password-new">Nueva Contraseña</label>
|
||||||
|
<app-input
|
||||||
|
id="reset-password-new"
|
||||||
|
type="password"
|
||||||
|
placeholder="Nueva Contraseña"
|
||||||
|
[value]="form.controls.password.value"
|
||||||
|
[invalid]="showControlError('password')"
|
||||||
|
(valueChange)="updatePassword('password', $event)"
|
||||||
|
/>
|
||||||
|
@if (getControlError('password'); as errorMessage) {
|
||||||
|
<small class="text-danger">{{ errorMessage }}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
<label class="visually-hidden" for="reset-password-confirmation">
|
||||||
|
Repetir Nueva Contraseña
|
||||||
|
</label>
|
||||||
|
<app-input
|
||||||
|
id="reset-password-confirmation"
|
||||||
|
type="password"
|
||||||
|
placeholder="Repetir Nueva Contraseña"
|
||||||
|
[value]="form.controls.password_confirmation.value"
|
||||||
|
[invalid]="showControlError('password_confirmation')"
|
||||||
|
(valueChange)="updatePassword('password_confirmation', $event)"
|
||||||
|
/>
|
||||||
|
@if (getControlError('password_confirmation'); as errorMessage) {
|
||||||
|
<small class="text-danger">{{ errorMessage }}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (serverError(); as errorMessage) {
|
||||||
|
<small class="text-danger" role="alert">{{ errorMessage }}</small>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-button
|
||||||
|
type="submit"
|
||||||
|
hostClass="w-100 d-block"
|
||||||
|
buttonClass="w-100"
|
||||||
|
[disabled]="isSubmitting()"
|
||||||
|
>
|
||||||
|
{{ isSubmitting() ? 'Guardando...' : 'Guardar' }}
|
||||||
|
</app-button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-password-page__form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
let showDangerToast: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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<typeof vi.fn> }) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(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<string, string[]>; 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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards'
|
|||||||
import { LoginPageComponent } from './pages/login-page/login-page.component';
|
import { LoginPageComponent } from './pages/login-page/login-page.component';
|
||||||
import { hasMenuGuard } from '../../core/guards/menu.guard';
|
import { hasMenuGuard } from '../../core/guards/menu.guard';
|
||||||
import { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver';
|
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 { RegisterPageComponent } from './pages/register-page/register-page.component';
|
||||||
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
|
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
|
||||||
import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver';
|
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',
|
path: 'buscar',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
[placeholder]="placeholder()"
|
[placeholder]="placeholder()"
|
||||||
[value]="type() !== 'file' ? value() : null"
|
[value]="type() !== 'file' ? value() : null"
|
||||||
(input)="type() !== 'file' ? onValueChange($event) : null"
|
(input)="type() !== 'file' ? onValueChange($event) : null"
|
||||||
|
(paste)="onPaste($event)"
|
||||||
(change)="type() === 'file' ? onFileChange($event) : null"
|
(change)="type() === 'file' ? onFileChange($event) : null"
|
||||||
[attr.maxlength]="type() !== 'file' ? maxlength() : null"
|
[attr.maxlength]="type() !== 'file' ? maxlength() : null"
|
||||||
[attr.accept]="type() === 'file' ? accept() : null"
|
[attr.accept]="type() === 'file' ? accept() : null"
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export class InputComponent {
|
|||||||
|
|
||||||
readonly disabledChange = output<boolean>();
|
readonly disabledChange = output<boolean>();
|
||||||
readonly fileChange = output<File | null>();
|
readonly fileChange = output<File | null>();
|
||||||
|
readonly pasteEvent = output<ClipboardEvent>();
|
||||||
readonly visibleChange = output<boolean>();
|
readonly visibleChange = output<boolean>();
|
||||||
|
|
||||||
protected readonly inputType = computed(() => {
|
protected readonly inputType = computed(() => {
|
||||||
@@ -166,6 +167,10 @@ export class InputComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.inputElement()?.nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
protected onValueChange(event: Event): void {
|
protected onValueChange(event: Event): void {
|
||||||
this.value.set((event.target as HTMLInputElement).value);
|
this.value.set((event.target as HTMLInputElement).value);
|
||||||
}
|
}
|
||||||
@@ -176,6 +181,10 @@ export class InputComponent {
|
|||||||
this.fileChange.emit(file);
|
this.fileChange.emit(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onPaste(event: ClipboardEvent): void {
|
||||||
|
this.pasteEvent.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
protected toggleEditableState(): void {
|
protected toggleEditableState(): void {
|
||||||
if (this.type() !== 'editable') {
|
if (this.type() !== 'editable') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user