Add functionality to allow a user to change their email address when logged in

pull/91/head
Chris Veilleux 2022-08-15 13:59:10 -05:00
parent 3d21653c7c
commit 5d192c1bf8
11 changed files with 289 additions and 24 deletions

View File

@ -26,13 +26,15 @@ import { Account } from '@account/models/account.model';
import { environment } from '../../../environments/environment';
import { MembershipType } from '@account/models/membership.model';
import { handleError } from '@account/app/app.service';
import { AbstractControl } from '@angular/forms';
// URLs for the http requests
const ACCOUNT_URL = '/api/account';
const CHANGE_PASSWORD_URL = '/api/password-change';
const CHANGE_EMAIL_ADDRESS_URL = '/api/change-email';
const CHANGE_PASSWORD_URL = '/api/change-password';
const MEMBERSHIP_URL = '/api/memberships';
const VALIDATE_EMAIL_URL = '/api/validate-email';
const VERIFY_EMAIL_URL = '/api/verify-email';
export function storeRedirect() {
@ -87,4 +89,18 @@ export class ProfileService {
const codedPassword = btoa(newPassword);
return this.http.put(CHANGE_PASSWORD_URL, {password: codedPassword});
}
validateEmailAddress(token: string): Observable<any> {
const queryParams = {platform: 'Internal', token: token};
return this.http.get(VALIDATE_EMAIL_URL, {params: queryParams});
}
changeEmailAddress(newEmailAddress: string) {
const codedEmailAddress = btoa(newEmailAddress);
return this.http.put(CHANGE_EMAIL_ADDRESS_URL, {token: codedEmailAddress});
}
verifyEmailAddress(newEmailAddress: string) {
return this.http.put<string>(VERIFY_EMAIL_URL, {token: newEmailAddress});
}
}

View File

@ -20,7 +20,7 @@
</p>
</mat-card-content>
<mat-card-actions>
<a mat-button>CHANGE EMAIL ADDRESS</a>
<button mat-button (click)="onPasswordChange()">CHANGE PASSWORD</button>
<button mat-button (click)="openEmailAddressChangeDialog()">CHANGE EMAIL ADDRESS</button>
<button mat-button (click)="openPasswordChangeDialog()">CHANGE PASSWORD</button>
</mat-card-actions>
</mat-card>

View File

@ -26,6 +26,7 @@ import { Account } from '@account/models/account.model';
import { ChangePasswordComponent } from '@account/app/modules/profile/components/views/change-password/change-password.component';
import { ProfileService } from '@account/http/profile.service';
import { SnackbarComponent } from 'shared';
import { ChangeEmailComponent } from '@account/app/modules/profile/components/views/change-email/change-email.component';
const fiveSeconds = 5000;
@ -40,19 +41,16 @@ export class LoginComponent {
constructor(
private profileService: ProfileService,
public passwordDialog: MatDialog,
private changeEmailDialog: MatDialog,
public changePasswordDialog: MatDialog,
private snackbar: MatSnackBar
) { }
onPasswordChange() {
this.openPasswordDialog();
}
openPasswordDialog() {
openPasswordChangeDialog() {
const dialogConfig = new MatDialogConfig();
dialogConfig.disableClose = true;
dialogConfig.restoreFocus = true;
const dialogRef = this.passwordDialog.open(ChangePasswordComponent, dialogConfig);
const dialogRef = this.changePasswordDialog.open(ChangePasswordComponent, dialogConfig);
dialogRef.afterClosed().subscribe(
(newPassword) => {
if (newPassword) {
@ -61,24 +59,37 @@ export class LoginComponent {
);
}
updatePassword (newPassword) {
updatePassword (newPassword): void {
this.profileService.changePassword(newPassword).subscribe({
next: () => { this.openSuccessSnackbar(); },
error: () => { this.openErrorSnackbar(); }
next: () => { this.openSnackbar('success', 'Password successfully changed'); },
error: () => { this.openSnackbar('error', 'An error occurred changing the password'); }
});
}
openErrorSnackbar() {
const config = new MatSnackBarConfig();
config.duration = fiveSeconds;
config.data = {type: 'error', message: 'An error occurred changing the password'};
this.snackbar.openFromComponent(SnackbarComponent, config);
openEmailAddressChangeDialog() {
const dialogConfig = new MatDialogConfig();
dialogConfig.disableClose = true;
dialogConfig.restoreFocus = true;
const dialogRef = this.changeEmailDialog.open(ChangeEmailComponent, dialogConfig);
dialogRef.afterClosed().subscribe(
(newEmailAddress) => {
if (newEmailAddress) {
this.updateEmailAddress(newEmailAddress);
}}
);
}
openSuccessSnackbar() {
updateEmailAddress (newEmailAddress): void {
this.profileService.changeEmailAddress(newEmailAddress).subscribe({
next: () => { this.openSnackbar('info', 'Check your inbox for a link to verify the new email address'); },
error: () => { this.openSnackbar('error', 'An error occurred changing the email address'); }
});
}
openSnackbar(type: string, message: string): void {
const config = new MatSnackBarConfig();
config.duration = fiveSeconds;
config.data = {type: 'success', message: 'Password successfully changed'};
config.data = {type: type, message: message};
this.snackbar.openFromComponent(SnackbarComponent, config);
}
}

View File

@ -0,0 +1,31 @@
<mat-card class="mat-elevation-z0">
<mat-card-header>
<mat-card-title>Enter your new email address</mat-card-title>
</mat-card-header>
<mat-card-content>
<p class="mat-body">
Email messages will be sent to both the current and new email addresses.
The message sent to the new email address contains a verification link.
This change will not be applied to the account until the new email address
been verified.
</p>
<mat-form-field [appearance]="'outline'">
<mat-label>Email</mat-label>
<input matInput required [type]="'email'" [formControl]="emailControl">
<mat-error *ngIf="emailControl.invalid">
{{getEmailError()}}
</mat-error>
</mat-form-field>
</mat-card-content>
<mat-card-actions>
<button
mat-button
id="submit-button"
[disabled]="emailControl.invalid"
(click)="changeEmail()"
>
SUBMIT
</button>
<button mat-button id="cancel-button" (click)="onCancel()">CANCEL</button>
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,52 @@
// *****************************************************************************
// SPDX-License-Identifier: Apache-2.0
//
//
// Copyright (c) Mycroft AI Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use
// this file except in compliance with the License. You may obtain a copy of the
// License at http://www.apache.org/licenses/LICENSE-2.0
//
// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
// WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
// MERCHANTABLITY OR NON-INFRINGEMENT.
//
// See the Apache Version 2.0 License for specific language governing permissions
// and limitations under the License.
// *****************************************************************************
@use "@angular/material" as mat;
@use "components/buttons" as buttons;
@use "components/cards" as cards;
@use 'mycroft-theme' as theme;
mat-card {
@include cards.selene-card;
margin: 0;
padding: 0;
mat-card-content {
padding: 16px;
button {
margin-top: -8px;
fa-icon {
font-size: 20px;
color: mat.get-color-from-palette(theme.$mycroft-primary, 500);
margin-left: 8px;
}
}
mat-form-field {
width: 240px;
}
}
mat-card-actions {
#cancel-button {
background-color: white;
color: mat.get-color-from-palette(theme.$mycroft-accent, 800);
}
}
}

View File

@ -0,0 +1,64 @@
import { Component, OnInit } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, UntypedFormControl, ValidationErrors, Validators } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ProfileService } from '@account/http/profile.service';
export function uniqueEmailValidator(profileService: ProfileService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const token: string = control.value ? btoa(control.value) : '';
return profileService.validateEmailAddress(token).pipe(
map((response) => response.accountExists ? { duplicateEmail: true } : null),
catchError(() => null),
);
};
}
@Component({
selector: 'account-change-email',
templateUrl: './change-email.component.html',
styleUrls: ['./change-email.component.scss']
})
export class ChangeEmailComponent implements OnInit {
public emailControl: UntypedFormControl;
constructor(
private profileService: ProfileService,
private dialogRef: MatDialogRef<ChangeEmailComponent>,
) {
this.emailControl = new UntypedFormControl(
null,
{
validators: [Validators.email, Validators.required],
asyncValidators: [uniqueEmailValidator(this.profileService)],
}
);
}
ngOnInit(): void {
}
getEmailError(): string {
let errorMessage = '';
if (this.emailControl.hasError('email')) {
errorMessage = 'Must be a valid email address';
} else if (this.emailControl.hasError('required')) {
errorMessage = 'Email is required';
} else if (this.emailControl.hasError('duplicateEmail')) {
errorMessage = 'Account already exists for this email';
}
return errorMessage;
}
changeEmail() {
this.dialogRef.close(this.emailControl.value);
}
onCancel() {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,27 @@
<mat-card>
<mat-card-header>
<fa-icon mat-card-avatar [icon]="emailAddressIcon"></fa-icon>
<mat-card-title>Email address verification</mat-card-title>
</mat-card-header>
<mat-card-content>
<p class="mat-body">{{verificationMessage}}</p>
</mat-card-content>
<mat-card-actions>
<button
*ngIf="!emailVerified"
mat-button
id="login-button"
(click)="loginToAccount()"
>
LOGIN
</button>
<button
*ngIf="emailVerified"
mat-button
id="success-button"
(click)="navigateToDashboard()"
>
OK
</button>
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,7 @@
@use 'components/cards' as cards;
mat-card {
@include cards.selene-card;
margin-left: auto;
margin-right: auto;
}

View File

@ -0,0 +1,48 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { faEnvelopeCircleCheck, IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { environment } from '@account/environments/environment';
import { ProfileService } from '@account/http/profile.service';
@Component({
selector: 'account-verify-email',
templateUrl: './verify-email.component.html',
styleUrls: ['./verify-email.component.scss']
})
export class VerifyEmailComponent implements OnInit {
public emailAddressIcon: IconDefinition = faEnvelopeCircleCheck;
public emailVerified: boolean;
public verificationMessage: string;
constructor(
private route: ActivatedRoute,
private profileService: ProfileService,
private router: Router
) { }
ngOnInit(): void {
const resetToken = this.route.snapshot.queryParams['token'];
this.profileService.verifyEmailAddress(resetToken).subscribe({
next: () => {
this.verificationMessage = 'Thank you for verifying your new email address. Use it the next time you login.';
this.emailVerified = true;
},
error: (error) => {
if (error.status === 401) {
this.verificationMessage = 'Your need to be logged in to verify your email address. ' +
'The new email address has not yet been applied to your account so login using the email address being replaced.';
this.emailVerified = false;
}
}
});
}
loginToAccount(): void {
window.location.href = environment.mycroftUrls.singleSignOn + '/login?redirect=' + window.location.href;
}
navigateToDashboard(): void {
this.router.navigate(['/']);
}
}

View File

@ -23,6 +23,7 @@ import { AccountResolverService } from '@account/app/core/guards/account-resolve
import { MembershipResolverService } from '@account/app/core/guards/membership-resolver.service';
import { EditComponent } from './pages/edit/edit.component';
import { NewComponent } from './pages/new/new.component';
import { VerifyEmailComponent } from './pages/verify-email/verify-email.component';
const profileRoutes: Routes = [
{
@ -40,6 +41,10 @@ const profileRoutes: Routes = [
membershipTypes: MembershipResolverService
}
},
{
path: 'verify-email',
component: VerifyEmailComponent,
},
];
@NgModule({

View File

@ -36,13 +36,16 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgxStripeModule } from 'ngx-stripe';
import { AgreementsComponent } from './components/cards/agreements/agreements.component';
import { ChangeEmailComponent } from './components/views/change-email/change-email.component';
import { ChangePasswordComponent } from './components/views/change-password/change-password.component';
import { DeleteComponent } from './components/cards/delete/delete.component';
import { DeleteConfirmComponent } from './components/modals/delete-confirm/delete-confirm.component';
import { EditComponent } from './pages/edit/edit.component';
import { environment} from '../../../environments/environment';
import { LoginComponent } from './components/cards/login/login.component';
import { MembershipComponent } from './components/cards/membership/membership.component';
import { MembershipOptionsComponent } from './components/controls/membership-options/membership-options.component';
import { MembershipStepComponent } from './components/views/membership-step/membership-step.component';
import { NewComponent } from './pages/new/new.component';
import { PaymentComponent } from './components/views/payment/payment.component';
import { ProfileService } from '@account/http/profile.service';
@ -50,15 +53,16 @@ import { ProfileRoutingModule } from './profile-routing.module';
import { SharedModule } from 'shared';
import { UsernameStepComponent } from './components/views/username-step/username-step.component';
import { VerifyCardDialogComponent } from './components/views/payment/verify-card-dialog.component';
import { DeleteConfirmComponent } from './components/modals/delete-confirm/delete-confirm.component';
import { MembershipStepComponent } from './components/views/membership-step/membership-step.component';
import { VerifyEmailComponent } from './pages/verify-email/verify-email.component';
@NgModule({
declarations: [
// Profile view and edit
AgreementsComponent,
ChangeEmailComponent,
ChangePasswordComponent,
DeleteComponent,
DeleteConfirmComponent,
EditComponent,
LoginComponent,
MembershipComponent,
@ -70,7 +74,7 @@ import { MembershipStepComponent } from './components/views/membership-step/memb
MembershipOptionsComponent,
PaymentComponent,
VerifyCardDialogComponent,
DeleteConfirmComponent
VerifyEmailComponent
],
imports: [
CommonModule,