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
1 change: 1 addition & 0 deletions src/app/domain/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export class User {
isGoogleUser: boolean = false;
isRecaptchaInvalid: boolean = false;
isRecaptchaRequired: boolean;
isVerified: boolean;
language: string;
lastName: string;
microsoftUserId: string;
Expand Down
77 changes: 51 additions & 26 deletions src/app/login/login-home/login-home.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,28 @@ <h2 class="standalone__title accent" i18n>Sign in to WISE</h2>
work. We apologize for the inconvenience.
</p>
}
<p>
<mat-form-field appearance="fill" class="w-full">
<mat-label i18n>Username</mat-label>
<input
matInput
id="username"
name="username"
[(ngModel)]="credentials.username"
[disabled]="processing"
autofocus
/>
</mat-form-field>
</p>
<p>
<mat-form-field appearance="fill" class="w-full">
<mat-label i18n>Password</mat-label>
<input
matInput
id="password"
name="password"
type="password"
[disabled]="processing"
[(ngModel)]="credentials.password"
/>
</mat-form-field>
</p>
<mat-form-field appearance="fill" class="w-full">
<mat-label i18n>Username</mat-label>
<input
matInput
id="username"
name="username"
[(ngModel)]="credentials.username"
[disabled]="processing"
autofocus
/>
</mat-form-field>
<mat-form-field appearance="fill" class="w-full">
<mat-label i18n>Password</mat-label>
<input
matInput
id="password"
name="password"
type="password"
[disabled]="processing"
[(ngModel)]="credentials.password"
/>
</mat-form-field>
@if (isRecaptchaEnabled) {
<p class="center" i18n>
This site is protected by reCAPTCHA and the Google
Expand All @@ -56,6 +52,35 @@ <h2 class="standalone__title accent" i18n>Sign in to WISE</h2>
>
}
}
@if (verificationState() === 'confirmVerified') {
<p class="success center" i18n>Your email has been verified.</p>
} @else if (verificationState() === 'emailError') {
<p class="warn center" i18n>
There was an error sending the verification email. Please try again later.
</p>
} @else if (verificationState() === 'emailSent') {
<p class="success center" i18n>A verification email has been sent.</p>
} @else if (verificationState() === 'unverified') {
<p class="warn center">
<span i18n
>Your email has not been verified. Check your email for a verification link.</span
>
</p>
@if (allowResendEmail()) {
<p class="warn center" i18n>
<a href="#" (click)="resendEmail($event)">Click here</a> to resend the verification
email.
</p>
} @else {
<p class="warn center" i18n>
Please wait to send another verification email ({{ resendEmailWaitSeconds() }}).
</p>
}
} @else if (verificationState() === 'verificationError') {
<p class="warn center" i18n>
Your account could not be verified. Make sure you click the link sent to your email.
</p>
}
<p>
<button
mat-flat-button
Expand Down
75 changes: 62 additions & 13 deletions src/app/login/login-home/login-home.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { By } from '@angular/platform-browser';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { provideRouter, Router } from '@angular/router';
import { getErrorMessage } from '../../common/test-helper';
import { DebugElement } from '@angular/core';

let component: LoginHomeComponent;
let configService: ConfigService;
Expand All @@ -20,7 +21,7 @@ const redirectUrl: string = `${contextPath}/api/j_acegi_security_check`;
let router: Router;
let userService: UserService;

describe('LoginHomeComponent!', () => {
describe('LoginHomeComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [LoginHomeComponent],
Expand Down Expand Up @@ -76,35 +77,36 @@ function loginWithRecaptchaDisabled() {
component.isRecaptchaEnabled = false;
});
incorrectPassword();
correctPassword();
correctPasswordVerifiedAccount();
unverifiedAccount();
unverifiedAccountWaitToResendEmail();
});
}

function incorrectPassword() {
describe('user enters incorrect password', () => {
it('should show error message', fakeAsync(() => {
it('should show authentication error message', fakeAsync(() => {
spyOn(http, 'post').and.returnValue(of({}));
spyOn(http, 'get').and.returnValue(of(null));
spyOn(userService, 'isVerified').and.returnValue(of(true));
component.login();
tickAndDetectChanges();
const errorMessageElement = fixture.debugElement
.queryAll(By.css('p'))
.find(
(element) =>
element.nativeElement.textContent.trim() ===
'Username and password not recognized. Please try again.'
);
expect(errorMessageElement.nativeElement.classList.contains('warn')).toBeTruthy();
const errorMessageElement = getErrorMessageElement(
'Username and password not recognized. Please try again.'
);
expect(errorMessageElement).toBeDefined();
expect(errorMessageElement!.nativeElement.classList.contains('warn')).toBeTruthy();
expect(component.credentials.password).toEqual('');
}));
});
}

function correctPassword() {
describe('user enters correct password', () => {
function correctPasswordVerifiedAccount() {
describe('user enters correct password and account is verified', () => {
it('should navigate to home page', fakeAsync(() => {
spyOn(http, 'post').and.returnValue(of({}));
spyOn(http, 'get').and.returnValue(of({ id: 1 }));
spyOn(userService, 'isVerified').and.returnValue(of(true));
const routerNavigateSpy = spyOn(router, 'navigateByUrl');
component.login();
tickAndDetectChanges();
Expand All @@ -113,6 +115,47 @@ function correctPassword() {
});
}

function unverifiedAccount() {
describe('login attempt with unverified account', () => {
it('should show verification error message', fakeAsync(() => {
spyOn(userService, 'isVerified').and.returnValue(of(false));
component.login();
tickAndDetectChanges();
const errorMessageElement = getErrorMessageElement(
'Your email has not been verified. Check your email for a verification link.'
);
const resendLinkElement = getErrorMessageElement(
'Click here to resend the verification email.'
);
expect(errorMessageElement).toBeDefined();
expect(errorMessageElement!.nativeElement.classList.contains('warn')).toBeTruthy();
expect(resendLinkElement).toBeDefined();
expect(resendLinkElement!.nativeElement.classList.contains('warn')).toBeTruthy();
}));
});
}

function unverifiedAccountWaitToResendEmail() {
describe('login attempt with unverified account and must wait to resend the email', () => {
it('should show verification error message with countdown to resend', fakeAsync(() => {
spyOn(userService, 'isVerified').and.returnValue(of(false));
component['resendEmailWaitSeconds'].set(60);
component.login();
tickAndDetectChanges();
const errorMessageElement = getErrorMessageElement(
'Your email has not been verified. Check your email for a verification link.'
);
const resendLinkElement = getErrorMessageElement(
'Please wait to send another verification email (60).'
);
expect(errorMessageElement).toBeDefined();
expect(errorMessageElement!.nativeElement.classList.contains('warn')).toBeTruthy();
expect(resendLinkElement).toBeDefined();
expect(resendLinkElement!.nativeElement.classList.contains('warn')).toBeTruthy();
}));
});
}

function loginWithRecaptchaEnabled() {
xdescribe('recaptcha is enabled', () => {
beforeEach(() => {
Expand All @@ -138,3 +181,9 @@ function tickAndDetectChanges() {
tick();
fixture.detectChanges();
}

function getErrorMessageElement(errorMsg: string): DebugElement | undefined {
return fixture.debugElement
.queryAll(By.css('p'))
.find((element) => element.nativeElement.textContent.trim() === errorMsg);
}
57 changes: 56 additions & 1 deletion src/app/login/login-home/login-home.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { Component, OnInit, signal, ViewChild } from '@angular/core';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { UserService } from '../../services/user.service';
import { ConfigService } from '../../services/config.service';
Expand All @@ -11,6 +11,7 @@ import { MatInput } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatDivider } from '@angular/material/divider';
import { HttpClient, HttpParams } from '@angular/common/http';

@Component({
imports: [
Expand Down Expand Up @@ -42,10 +43,17 @@ export class LoginHomeComponent implements OnInit {
passwordError: boolean = false;
processing: boolean = false;
@ViewChild('recaptchaRef', { static: false }) recaptchaRef: any;
private resendEmailEndpoint = '/api/teacher/register/send-verify-email';
private resendEmailInterval: any;
protected resendEmailWaitSeconds = signal<number>(0);
protected showSocialLogin: boolean;
protected verificationState = signal<
'none' | 'confirmVerified' | 'emailError' | 'emailSent' | 'unverified' | 'verificationError'
>('none');

constructor(
private configService: ConfigService,
private http: HttpClient,
private router: Router,
private route: ActivatedRoute,
private recaptchaV3Service: ReCaptchaV3Service,
Expand Down Expand Up @@ -77,9 +85,24 @@ export class LoginHomeComponent implements OnInit {
if (params['accessCode'] != null) {
this.accessCode = params['accessCode'];
}
if (params['verified']) {
if (params['verified'] === 'true') {
this.verificationState.set('confirmVerified');
} else if (params['verified'] === 'error') {
this.verificationState.set('verificationError');
}
}
});
this.isReLoginDueToErrorSavingData = this.isRedirectToAppRoutes();
this.isRecaptchaEnabled = this.configService.isRecaptchaEnabled();

this.resendEmailInterval = setInterval(() => {
this.resendEmailWaitSeconds.update((current) => current - 1);
}, 1000);
}

ngOnDestroy(): void {
clearInterval(this.resendEmailInterval);
}

private isRedirectToAppRoutes(): boolean {
Expand All @@ -90,11 +113,24 @@ export class LoginHomeComponent implements OnInit {
async login(): Promise<void> {
this.processing = true;
this.passwordError = false;
this.verificationState.set('none');
if (this.isRecaptchaEnabled) {
this.credentials.recaptchaResponse = await lastValueFrom(
this.recaptchaV3Service.execute('importantAction')
);
}
this.userService.isVerified(this.credentials.username).subscribe((isVerified) => {
if (isVerified) {
this.authenticateUser();
} else {
this.processing = false;
this.credentials.password = '';
this.verificationState.set('unverified');
}
});
}

private authenticateUser(): void {
this.userService.authenticate(this.credentials, (response: any) => {
if (this.userService.isAuthenticated) {
this.router.navigateByUrl(this.getRedirectUrl(''));
Expand Down Expand Up @@ -136,4 +172,23 @@ export class LoginHomeComponent implements OnInit {
private appendAccessCodeParameter(url: string): string {
return `${url}${url.includes('?') ? '&' : '?'}accessCode=${this.accessCode}`;
}

protected allowResendEmail(): boolean {
return this.resendEmailWaitSeconds() <= 0;
}

protected resendEmail(e: Event): void {
e.preventDefault();
this.resendEmailWaitSeconds.set(60);
this.verificationState.set('none');
const params = new HttpParams().set('username', this.credentials.username);
this.http.post<String>(`${this.resendEmailEndpoint}`, null, { params }).subscribe({
next: () => {
this.verificationState.set('emailSent');
},
error: () => {
this.verificationState.set('emailError');
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ <h2 class="standalone__title accent" i18n>Your WISE account has been created!</h
>.
</p>
}
<p i18n>You should receive an email with your account details shortly.</p>
@if (!socialAccount) {
<p i18n>To sign in, you must verify your account.</p>
<p i18n>
You should receive an email shortly with instructions to complete your registration.
</p>
} @else {
<p i18n>You should receive an email with your account details shortly.</p>
}
<p>
@if (!socialAccount) {
<a mat-flat-button color="primary" (click)="login()" i18n>Sign In to Get Started</a>
}
@if (isUsingGoogleId) {
<a
class="button--social-login button--google"
Expand Down
7 changes: 7 additions & 0 deletions src/app/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class UserService {
private googleUserUrl = '/api/google-user/get-user';
private checkAuthenticationUrl = '/api/user/check-authentication';
private changePasswordUrl = '/api/user/password';
private checkVerifiedUrl = '/api/teacher/is-verified';
private languagesUrl = '/api/user/languages';
private contactUrl = '/api/contact';
private unlinkGoogleAccountUrl = '/api/google-user/unlink-account';
Expand Down Expand Up @@ -76,6 +77,12 @@ export class UserService {
return this.getUser().getValue().isGoogleUser;
}

isVerified(username: string): Observable<boolean> {
return this.http.get<boolean>(this.checkVerifiedUrl, {
params: new HttpParams().set('username', username)
});
}

retrieveUserPromise(): Promise<User> {
return this.retrieveUser().toPromise();
}
Expand Down
Loading