Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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.blockFurtherAttempts(
$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.`
);
}

protected blockFurtherAttempts(message: string): void {
this.message = message;
this.getFormGroup().disable();
this.showForgotPasswordLink = true;
}

protected setErrorOccurredMessage(): void {
this.message = $localize`An error occurred. Please try again.`;
}

protected clearMessage(): void {
this.message = '';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
<mat-card-content>
<form role="form" (submit)="submit()" [formGroup]="changePasswordFormGroup">
<h2 class="standalone__title accent" i18n>Change Password</h2>
@if (message) {
<p class="warn">{{ message }}</p>
}
<new-password-and-confirm [formGroup]="changePasswordFormGroup"></new-password-and-confirm>
<p>
<button
Expand All @@ -21,6 +18,18 @@ <h2 class="standalone__title accent" i18n>Change Password</h2>
</button>
</p>
</form>
<!-- The live region has to be in the page before the message arrives, or assistive technology
announces nothing. The paragraph stays guarded so an empty one does not add its margin. -->
<div role="alert">
@if (message) {
<p class="warn">{{ message }}</p>
}
</div>
@if (showForgotPasswordLink) {
<p>
<a routerLink="/forgot/student/password" i18n>Forgot Student Password</a>
</p>
}
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-content class="center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -60,6 +69,49 @@ 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 disable the form and show the forgot password link when the answer is rejected', () => {
const password = PasswordRequirementComponent.VALID_PASSWORD;
component.changePasswordFormGroup.controls['newPassword'].setValue(password);
component.changePasswordFormGroup.controls['confirmNewPassword'].setValue(password);
fixture.detectChanges();
const studentService = TestBed.inject(StudentService);
spyOn(studentService, 'changePassword').and.returnValue(
throwError(() => ({ error: { messageCode: 'incorrectAnswer' } }))
);
component.submit();
fixture.detectChanges();
expect(getErrorMessage()).toContain('was not accepted');
expect(component.changePasswordFormGroup.controls['newPassword'].disabled).toBe(true);
expect(getSubmitButton().disabled).toBe(true);
expect(getForgotPasswordLink()).not.toBeNull();
});

it('should keep the live region in the page before anything has gone wrong', () => {
expect(fixture.debugElement.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
});

it('should not render the message paragraph before anything has gone wrong', () => {
expect(fixture.debugElement.nativeElement.querySelector('.warn')).toBeNull();
});

it('should submit and navigate to the complete page', () => {
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;

Expand All @@ -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();
Expand Down Expand Up @@ -77,11 +82,29 @@ export class ForgotStudentPasswordChangeComponent {
case 'invalidPassword':
injectPasswordErrors(this.changePasswordFormGroup, error);
break;
case 'incorrectAnswer':
this.incorrectAnswer();
break;
case 'tooManyFailedAnswerAttempts':
this.tooManyFailedAnswerAttempts();
break;
default:
this.setErrorOccurredMessage();
}
}

/**
* The answer was carried over from the security question step and cannot be corrected on this
* page, so resubmitting can only send the same rejected answer again while spending another of
* the attempts the server allows before it locks the reset. Send the student back to the start
* of the flow instead.
*/
private incorrectAnswer(): void {
this.blockFurtherAttempts(
$localize`The answer to your security question was not accepted. Please go back to the Forgot Student Password page to try again, or ask your teacher to change your password.`
);
}

private getNewPassword(): string {
return this.getControlFieldValue(NewPasswordAndConfirmComponent.NEW_PASSWORD_FORM_CONTROL_NAME);
}
Expand All @@ -96,14 +119,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
<mat-card-content>
<form role="form" (submit)="submit()" [formGroup]="answerSecurityQuestionFormGroup">
<h2 class="standalone__title accent" i18n>Answer Security Question</h2>
@if (message) {
<p class="warn">{{ message }}</p>
}
<p>
<mat-form-field appearance="fill" class="w-full">
<mat-label>{{ question }}</mat-label>
Expand Down Expand Up @@ -44,6 +41,18 @@ <h2 class="standalone__title accent" i18n>Answer Security Question</h2>
</button>
</p>
</form>
<!-- The live region has to be in the page before the message arrives, or assistive technology
announces nothing. The paragraph stays guarded so an empty one does not add its margin. -->
<div role="alert">
@if (message) {
<p class="warn">{{ message }}</p>
}
</div>
@if (showForgotPasswordLink) {
<p>
<a routerLink="/forgot/student/password" i18n>Forgot Student Password</a>
</p>
}
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-content class="center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,39 @@ async function changePassword() {
expect(submitButton.disabled).toBe(false);
});

it('should not render the message paragraph before anything has gone wrong', () => {
expect(getWarnElement()).toBeNull();
});

it('should keep the live region in the page before anything has gone wrong', () => {
expect(fixture.debugElement.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
});

it('should show the incorrect answer message', waitForAsync(() => {
submitAndReceiveResponse('checkSecurityAnswer', 'failure', 'incorrectAnswer');
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');
Expand Down Expand Up @@ -142,9 +170,21 @@ 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 getWarnElement() {
return fixture.debugElement.nativeElement.querySelector('.warn');
}

function getAnswerInput() {
return fixture.debugElement.nativeElement.querySelector('#answer');
}

function getForgotPasswordLink() {
return fixture.debugElement.nativeElement.querySelector('a[href="/forgot/student/password"]');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -117,8 +125,4 @@ export class ForgotStudentPasswordSecurityComponent {
setControlFieldValue(name: string, value: string): void {
this.answerSecurityQuestionFormGroup.controls[name].setValue(value);
}

private clearMessage(): void {
this.message = '';
}
}
Loading
Loading