Skip to content
Open
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,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 = '';
}
}
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,12 @@ <h2 class="standalone__title accent" i18n>Change Password</h2>
</button>
</p>
</form>
<p class="warn">{{ message }}</p>
@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,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');
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,6 +82,9 @@ export class ForgotStudentPasswordChangeComponent {
case 'invalidPassword':
injectPasswordErrors(this.changePasswordFormGroup, error);
break;
case 'tooManyFailedAnswerAttempts':
this.tooManyFailedAnswerAttempts();
break;
default:
this.setErrorOccurredMessage();
}
Expand All @@ -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
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,12 @@ <h2 class="standalone__title accent" i18n>Answer Security Question</h2>
</button>
</p>
</form>
<p class="warn">{{ message }}</p>
@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 @@ -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');
Expand Down Expand Up @@ -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"]');
}
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