Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
859aa99
Handle basic email verification
Aaron-Detre Jul 15, 2026
66628fc
Removed sign in button and updated text on teacher registration compl…
Aaron-Detre Jul 15, 2026
93f75b1
Show a confirmation when a user's account has just been verified
Aaron-Detre Jul 15, 2026
bc048ab
Allow teachers to resend the verification email
Aaron-Detre Jul 16, 2026
7c68662
Update endpoints
Aaron-Detre Jul 17, 2026
5c3bd8b
Show email sent confirmation if email was successful, otherwise show …
Aaron-Detre Jul 17, 2026
0933bac
Combine signals into one state signal
Aaron-Detre Jul 21, 2026
53c59d9
Write/Fix tests
Aaron-Detre Jul 21, 2026
03fc2a7
Show error if verification code does not match any user in database
Aaron-Detre Jul 21, 2026
71a91f5
Updated messages
github-actions[bot] Jul 21, 2026
42e4db2
Merge branch 'develop' into email-verification
Aaron-Detre Jul 21, 2026
2d1147b
Merge branch 'email-verification' of https://github.com/WISE-Communit…
Aaron-Detre Jul 21, 2026
8270965
Updated messages
github-actions[bot] Jul 21, 2026
7cb7b97
Split verification messages into separate i18n messages, cleaned up t…
breity Aug 3, 2026
fa1b20a
Fix test
breity Aug 3, 2026
ba859e6
Updated messages
github-actions[bot] Aug 3, 2026
8caf35a
Update resend email endpoint
Aaron-Detre Aug 6, 2026
cc4ac2d
Check verification status in authentication call
Aaron-Detre Aug 6, 2026
797d987
Merge branch 'email-verification' of https://github.com/WISE-Communit…
Aaron-Detre Aug 9, 2026
f08470f
Added spinner when sending email
Aaron-Detre Aug 9, 2026
97e6ce8
Fixed tests
Aaron-Detre Aug 9, 2026
c5708da
Updated messages
github-actions[bot] Aug 9, 2026
421e60b
Removed is-verified endpoint
Aaron-Detre Aug 12, 2026
14a9754
Fixed tests
Aaron-Detre Aug 13, 2026
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
81 changes: 55 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,39 @@ <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() === 'sendingEmail') {
<div class="email-spinner-parent">
<mat-spinner class="email-spinner" [diameter]="30" />
</div>
} @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
8 changes: 8 additions & 0 deletions src/app/login/login-home/login-home.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.email-spinner-parent {
display: grid;
place-items: center;
}

.email-spinner {
margin: 20px;
}
77 changes: 64 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,32 +77,31 @@ 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));
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 }));
Expand All @@ -113,6 +113,51 @@ function correctPassword() {
});
}

function unverifiedAccount() {
describe('login attempt with unverified account', () => {
it('should show verification error message', fakeAsync(() => {
spyOn(userService, 'authenticate').and.callFake(() => {
component['verificationState'].set('unverified');
});
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, 'authenticate').and.callFake(() => {
component['verificationState'].set('unverified');
});
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 +183,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);
}
69 changes: 63 additions & 6 deletions 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,19 +11,22 @@ 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';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';

@Component({
imports: [
FormsModule,
MatButton,
MatCard,
MatCardContent,
FormsModule,
MatDivider,
MatError,
MatFormField,
MatLabel,
MatInput,
MatError,
MatButton,
MatLabel,
MatProgressBar,
MatDivider,
MatProgressSpinnerModule,
RouterLink,
RecaptchaV3Module
],
Expand All @@ -42,10 +45,23 @@ export class LoginHomeComponent implements OnInit {
passwordError: boolean = false;
processing: boolean = false;
@ViewChild('recaptchaRef', { static: false }) recaptchaRef: any;
private resendEmailEndpoint = '/api/teacher/send-verify-email';
private resendEmailInterval: any;
protected resendEmailWaitSeconds = signal<number>(0);
protected showSocialLogin: boolean;
protected verificationState = signal<
| 'none'
| 'confirmVerified'
| 'emailError'
| 'emailSent'
| 'sendingEmail'
| '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 +93,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 +121,16 @@ 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.authenticateUser();
}

private authenticateUser(): void {
this.userService.authenticate(this.credentials, (response: any) => {
if (this.userService.isAuthenticated) {
this.router.navigateByUrl(this.getRedirectUrl(''));
Expand All @@ -103,6 +139,8 @@ export class LoginHomeComponent implements OnInit {
this.credentials.password = '';
if (response.isRecaptchaVerificationFailed) {
this.isRecaptchaVerificationFailed = true;
} else if (response.isTeacherVerificationFailed) {
this.verificationState.set('unverified');
} else {
this.passwordError = true;
}
Expand Down Expand Up @@ -136,4 +174,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('sendingEmail');
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
Loading
Loading