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 () => {
|
||||
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { 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: () =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user