diff --git a/src/app/forgot/student/abstract-forgot-student-password.component.ts b/src/app/forgot/student/abstract-forgot-student-password.component.ts new file mode 100644 index 00000000000..08a56a1e1bb --- /dev/null +++ b/src/app/forgot/student/abstract-forgot-student-password.component.ts @@ -0,0 +1,32 @@ +import { Directive } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +@Directive() +export abstract class AbstractForgotStudentPasswordComponent { + protected message: string = ''; + protected processing: boolean = false; + protected showForgotPasswordLink: boolean = false; + + protected abstract getFormGroup(): FormGroup; + + /** + * The server temporarily blocks the reset after several incorrect security answers. Disabling + * the form stops the student from immediately trying again, and the link sends them back to the + * start of the flow. Unlike the teacher flow there is no new verification code to generate, so + * the message asks them to wait or to ask their teacher rather than promising the link unblocks + * them. + */ + protected tooManyFailedAnswerAttempts(): void { + this.message = $localize`You have entered an incorrect answer too many times. For security reasons, we will lock the ability to change your password for 10 minutes. After 10 minutes, please go back to the Forgot Student Password page to try again, or ask your teacher to change your password.`; + this.getFormGroup().disable(); + this.showForgotPasswordLink = true; + } + + protected setErrorOccurredMessage(): void { + this.message = $localize`An error occurred. Please try again.`; + } + + protected clearMessage(): void { + this.message = ''; + } +} diff --git a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html index 2ff0193d36a..eb895350d2e 100644 --- a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html +++ b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html @@ -2,9 +2,6 @@

Change Password

- @if (message) { -

{{ message }}

- }

+

{{ message }}

+ @if (showForgotPasswordLink) { +

+ Forgot Student Password +

+ }
diff --git a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts index dee2dfb837b..aab33b0a27c 100644 --- a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts +++ b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts @@ -3,7 +3,7 @@ import { ForgotStudentPasswordChangeComponent } from './forgot-student-password- import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { StudentService } from '../../../student/student.service'; import { provideRouter, Router } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable, throwError } from 'rxjs'; import { PasswordRequirementComponent } from '../../../password/password-requirement/password-requirement.component'; class MockStudentService { @@ -31,6 +31,15 @@ describe('ForgotStudentPasswordChangeComponent', () => { return fixture.debugElement.nativeElement.querySelector('button[type="submit"]'); }; + const getErrorMessage = () => { + const errorMessageDiv = fixture.debugElement.nativeElement.querySelector('.warn'); + return errorMessageDiv == null ? '' : errorMessageDiv.textContent; + }; + + const getForgotPasswordLink = () => { + return fixture.debugElement.nativeElement.querySelector('a[href="/forgot/student/password"]'); + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [BrowserAnimationsModule, ForgotStudentPasswordChangeComponent], @@ -60,6 +69,24 @@ describe('ForgotStudentPasswordChangeComponent', () => { expect(submitButton.disabled).toBe(false); }); + it('should disable the form and show the forgot password link when there are too many failed attempts', () => { + const password = PasswordRequirementComponent.VALID_PASSWORD; + component.changePasswordFormGroup.controls['newPassword'].setValue(password); + component.changePasswordFormGroup.controls['confirmNewPassword'].setValue(password); + fixture.detectChanges(); + expect(getSubmitButton().disabled).toBe(false); + const studentService = TestBed.inject(StudentService); + spyOn(studentService, 'changePassword').and.returnValue( + throwError(() => ({ error: { messageCode: 'tooManyFailedAnswerAttempts' } })) + ); + component.submit(); + fixture.detectChanges(); + expect(getErrorMessage()).toContain('too many times'); + expect(component.changePasswordFormGroup.controls['newPassword'].disabled).toBe(true); + expect(getSubmitButton().disabled).toBe(true); + expect(getForgotPasswordLink()).not.toBeNull(); + }); + it('should submit and navigate to the complete page', () => { const router = TestBed.inject(Router); const navigateSpy = spyOn(router, 'navigate'); diff --git a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts index 17d73bd8a6b..8cc0fec8f49 100644 --- a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts +++ b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts @@ -11,6 +11,7 @@ import { MatProgressBar } from '@angular/material/progress-bar'; import { MatButton } from '@angular/material/button'; import { PasswordModule } from '../../../password/password.module'; import { MatCard, MatCardContent } from '@angular/material/card'; +import { AbstractForgotStudentPasswordComponent } from '../abstract-forgot-student-password.component'; @Component({ templateUrl: './forgot-student-password-change.component.html', @@ -27,11 +28,9 @@ import { MatCard, MatCardContent } from '@angular/material/card'; RouterLink ] }) -export class ForgotStudentPasswordChangeComponent { +export class ForgotStudentPasswordChangeComponent extends AbstractForgotStudentPasswordComponent { @Input() answer: string; changePasswordFormGroup: FormGroup = this.fb.group({}); - protected message: string = ''; - protected processing: boolean = false; @Input() questionKey: string; @Input() username: string; @@ -40,7 +39,13 @@ export class ForgotStudentPasswordChangeComponent { private fb: FormBuilder, private router: Router, private studentService: StudentService - ) {} + ) { + super(); + } + + protected getFormGroup(): FormGroup { + return this.changePasswordFormGroup; + } ngAfterViewChecked(): void { this.changeDetectorRef.detectChanges(); @@ -77,6 +82,9 @@ export class ForgotStudentPasswordChangeComponent { case 'invalidPassword': injectPasswordErrors(this.changePasswordFormGroup, error); break; + case 'tooManyFailedAnswerAttempts': + this.tooManyFailedAnswerAttempts(); + break; default: this.setErrorOccurredMessage(); } @@ -96,14 +104,6 @@ export class ForgotStudentPasswordChangeComponent { return this.changePasswordFormGroup.get(fieldName).value; } - private setErrorOccurredMessage(): void { - this.message = $localize`An error occurred. Please try again.`; - } - - private clearMessage(): void { - this.message = ''; - } - private goToSuccessPage(): void { const params = { username: this.username diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html index 8139431fa63..b073ca26e32 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html @@ -2,9 +2,6 @@

Answer Security Question

- @if (message) { -

{{ message }}

- }

{{ question }} @@ -44,6 +41,12 @@

Answer Security Question

+

{{ message }}

+ @if (showForgotPasswordLink) { +

+ Forgot Student Password +

+ }
diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts index 8ca7e0ea6eb..ae5f291091b 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts @@ -87,6 +87,26 @@ async function changePassword() { expect(getErrorMessage()).toContain('Incorrect answer'); })); + it('should show the too many failed attempts message', waitForAsync(() => { + submitAndReceiveResponse('checkSecurityAnswer', 'failure', 'tooManyFailedAnswerAttempts'); + expect(getErrorMessage()).toContain('too many times'); + })); + + it('should disable the form and show the forgot password link when there are too many failed attempts', waitForAsync(() => { + component.setControlFieldValue('answer', 'cookies'); + fixture.detectChanges(); + expect(getSubmitButton().disabled).toBe(false); + submitAndReceiveResponse('checkSecurityAnswer', 'failure', 'tooManyFailedAnswerAttempts'); + expect(getAnswerInput().disabled).toBe(true); + expect(getSubmitButton().disabled).toBe(true); + expect(getForgotPasswordLink()).not.toBeNull(); + })); + + it('should show the error occurred message for an unrecognized response code', waitForAsync(() => { + submitAndReceiveResponse('checkSecurityAnswer', 'failure', 'invalidUsername'); + expect(getErrorMessage()).toContain('An error occurred'); + })); + it('should navigate to change password page', () => { const router = TestBed.inject(Router); const navigateSpy = spyOn(router, 'navigate'); @@ -142,9 +162,17 @@ function createObservableResponse(status, messageCode) { function getErrorMessage() { const errorMessageDiv = fixture.debugElement.nativeElement.querySelector('.warn'); - return errorMessageDiv.textContent; + return errorMessageDiv == null ? '' : errorMessageDiv.textContent; } function getSubmitButton() { return fixture.debugElement.nativeElement.querySelector('button[type="submit"]'); } + +function getAnswerInput() { + return fixture.debugElement.nativeElement.querySelector('#answer'); +} + +function getForgotPasswordLink() { + return fixture.debugElement.nativeElement.querySelector('a[href="/forgot/student/password"]'); +} diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts index 4f8b7cb83bf..05c4df2143f 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts @@ -18,6 +18,7 @@ import { MatButton } from '@angular/material/button'; import { MatInput } from '@angular/material/input'; import { MatFormField, MatLabel, MatError } from '@angular/material/form-field'; import { MatCard, MatCardContent } from '@angular/material/card'; +import { AbstractForgotStudentPasswordComponent } from '../abstract-forgot-student-password.component'; @Component({ templateUrl: './forgot-student-password-security.component.html', @@ -38,14 +39,12 @@ import { MatCard, MatCardContent } from '@angular/material/card'; RecaptchaV3Module ] }) -export class ForgotStudentPasswordSecurityComponent { +export class ForgotStudentPasswordSecurityComponent extends AbstractForgotStudentPasswordComponent { protected answer: string; protected answerSecurityQuestionFormGroup: FormGroup = this.fb.group({ answer: new FormControl('', [Validators.required]) }); isRecaptchaEnabled: boolean = this.configService.isRecaptchaEnabled(); - protected message: string; - protected processing: boolean = false; @Input() question: string; @Input() questionKey: string; @Input() username: string; @@ -56,7 +55,13 @@ export class ForgotStudentPasswordSecurityComponent { private recaptchaV3Service: ReCaptchaV3Service, private router: Router, private studentService: StudentService - ) {} + ) { + super(); + } + + protected getFormGroup(): FormGroup { + return this.answerSecurityQuestionFormGroup; + } async submit() { this.processing = true; @@ -94,16 +99,19 @@ export class ForgotStudentPasswordSecurityComponent { } securityAnswerError(response: any): void { - let message; switch (response.messageCode) { case 'incorrectAnswer': - message = $localize`Incorrect answer, please try again. If you can't remember the answer to your security question, please ask your teacher to change your password or contact us for assistance.`; + this.message = $localize`Incorrect answer, please try again. If you can't remember the answer to your security question, please ask your teacher to change your password or contact us for assistance.`; + break; + case 'tooManyFailedAnswerAttempts': + this.tooManyFailedAnswerAttempts(); break; case 'recaptchaResponseInvalid': - message = $localize`Recaptcha failed. Please reload the page and try again.`; + this.message = $localize`Recaptcha failed. Please reload the page and try again.`; break; + default: + this.setErrorOccurredMessage(); } - this.message = message; } getAnswer() { @@ -117,8 +125,4 @@ export class ForgotStudentPasswordSecurityComponent { setControlFieldValue(name: string, value: string): void { this.answerSecurityQuestionFormGroup.controls[name].setValue(value); } - - private clearMessage(): void { - this.message = ''; - } }