Forgot password flow

This commit is contained in:
2026-07-27 09:42:22 -03:00
parent 565bc769ed
commit 9dcedc9382
21 changed files with 1695 additions and 4 deletions

View File

@@ -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));

View File

@@ -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';
}

View File

@@ -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: [

View File

@@ -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<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 {
const tenant = this.tenantService.getTenant();
if (!tenant) {

View File

@@ -40,7 +40,11 @@
<small class="text-danger">{{ errorMessage }}</small>
}
<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
</button>
</div>
@@ -56,7 +60,7 @@
>
{{ isSubmitting() ? 'Ingresando...' : 'Ingresar' }}
</app-button>
<app-button
type="button"
variant="borderless"

View File

@@ -53,6 +53,10 @@ export class LoginPageComponent {
void this.router.navigate(['/register']);
}
goToRecoverPassword(): void {
void this.router.navigate(['/recuperar-contrasena']);
}
onSubmit(): void {
this.submittedState.set(true);
this.serverErrorState.set(null);

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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.');
});
});

View File

@@ -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.';
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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.');
});
});

View File

@@ -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.';
}
}

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
:host {
display: block;
}
.reset-password-page__form {
width: 100%;
}

View File

@@ -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();
});
});

View File

@@ -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.';
}
}

View File

@@ -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: () =>

View File

@@ -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"

View File

@@ -85,6 +85,7 @@ export class InputComponent {
readonly disabledChange = output<boolean>();
readonly fileChange = output<File | null>();
readonly pasteEvent = output<ClipboardEvent>();
readonly visibleChange = output<boolean>();
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;