Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DON-752: Create login page #1401

Merged
merged 13 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 11 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
37 changes: 36 additions & 1 deletion src/app/app-routing.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import { Routes } from '@angular/router';
import {Router, Routes} from '@angular/router';

import { CampaignListResolver } from './campaign-list.resolver';
import { CampaignResolver } from './campaign.resolver';
import { CharityCampaignsResolver } from './charity-campaigns.resolver';
import {campaignStatsResolver} from "./campaign-stats-resolver";
import {LoginComponent} from "./login/login.component";
import {environment} from "../environments/environment";
import {inject} from "@angular/core";
import {IdentityService} from "./identity.service";

const redirectToHomeIfLoggedIn = () => {
const identityService = inject(IdentityService);
const router = inject(Router);

const isLoggedIn = identityService.probablyHaveLoggedInPerson();
if (! isLoggedIn) {
return true;
} else {
return router.parseUrl('/');
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo - inject the Activated route and redirect the same way that the login component does on successful login, i.e. not always to the home page.

};

const routes: Routes = [
{
Expand Down Expand Up @@ -122,6 +138,12 @@ const routes: Routes = [
loadChildren: () => import('./my-account/my-account.module')
.then(c => c.MyAccountModule),
},
{
path: 'my-account',
pathMatch: 'full',
loadChildren: () => import('./my-account/my-account.module')
.then(c => c.MyAccountModule),
},
// This is effectively our 404 handler because we support any string as meta-campaign
bdsl marked this conversation as resolved.
Show resolved Hide resolved
// slug. So check `CampaignResolver` for adjusting what happens if the slug doesn't
// match a campaign.
Expand All @@ -136,4 +158,17 @@ const routes: Routes = [
},
];

if (environment.environmentId !== 'production') {
routes.unshift(
{
path: 'login',
pathMatch: 'full',
component: LoginComponent,
canActivate: [
redirectToHomeIfLoggedIn,
],
},
);
}

export {routes};
Empty file.
7 changes: 7 additions & 0 deletions src/app/login/login.component.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { LoginComponent } from './login.component'

describe('LoginComponent', () => {
it('should mount', () => {
cy.mount(LoginComponent)
})
})
83 changes: 83 additions & 0 deletions src/app/login/login.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<main>
<div class="my-account">
<biggive-page-section>

<re-captcha
#captcha
size="invisible"
errorMode="handled"
(resolved)="captchaReturn($event)"
(errored)="captchaError()"
siteKey="{{ recaptchaIdSiteKey }}"
></re-captcha>

<div *ngIf="forgotPassword else logIn">
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may or may not want to move forgotPassword out to a separate page.


<h2 mat-dialog-title>Reset Password</h2>

<div *ngIf="!userAskedForResetLink">
<mat-dialog-content class="mat-typography">
<form [formGroup]="resetPasswordForm">
<mat-form-field class="b-w-100" color="primary">
<mat-label for="loginEmailAddress">Email address</mat-label>
<input matInput type="email" id="loginEmailAddress" formControlName="emailAddress" autocapitalize="off">
</mat-form-field>
<p class="b-rt-0">Please enter your email address.</p>
</form>
</mat-dialog-content>

<mat-dialog-actions align="end">
<button
id="reset-password-modal-submit"
mat-raised-button
[disabled]="!resetPasswordForm.valid || userAskedForResetLink"
(click)="resetPasswordClicked()"
color="primary"
>Reset Password</button>
</mat-dialog-actions>
</div>

<div *ngIf="userAskedForResetLink">
<mat-spinner *ngIf="resetPasswordSuccess === undefined" [diameter]="40" aria-label="Loading your details"></mat-spinner>
<p *ngIf="resetPasswordSuccess">Please check your email and click on the password reset link received.</p>
<p *ngIf="resetPasswordSuccess === false" class="error">Sorry, we encountered a problem. Please <a href="https://community.biggive.org/s/contact-us" target="_blank">contact us</a> for help.</p>
</div>
</div>

<ng-template #logIn>
<h2 mat-dialog-title>Donor Log in</h2>
<mat-dialog-content class="mat-typography">
<form [formGroup]="loginForm">
<mat-form-field class="b-w-100" color="primary">
<mat-label for="loginEmailAddress">Email address</mat-label>
<input matInput type="email" id="loginEmailAddress" formControlName="emailAddress" autocapitalize="off">
</mat-form-field>

<mat-form-field class="b-w-100" color="primary">
<mat-label for="loginPassword">Password</mat-label>
<input matInput type="password" id="loginPassword" formControlName="password">
</mat-form-field>

<p *ngIf="loginError" class="error" aria-live="assertive">
Login error: {{ loginError }}
</p>
</form>
</mat-dialog-content>

<mat-dialog-actions style="justify-content: space-between;">
<a (click)="forgotPasswordClicked()">Forgot Password?</a>
<div>
<button
id="login-modal-submit"
mat-raised-button
[disabled]="!loginForm.valid || loggingIn"
(click)="login()"
color="primary"
>Log in</button>
</div>
</mat-dialog-actions>
</ng-template>

</biggive-page-section>
</div>
</main>
133 changes: 133 additions & 0 deletions src/app/login/login.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {Component, OnInit, ViewChild} from '@angular/core';
import { CommonModule } from '@angular/common';
import {ComponentsModule} from "@biggive/components-angular";
import {MatButtonModule} from "@angular/material/button";
import {MatDialogModule} from "@angular/material/dialog";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {RecaptchaComponent, RecaptchaModule} from "ng-recaptcha";
import {Credentials} from "../credentials.model";
import {IdentityService} from "../identity.service";
import {environment} from "../../environments/environment";
import {EMAIL_REGEXP} from "../validators/patterns";
import {ActivatedRoute, Router} from "@angular/router";
import {MatSnackBar, MatSnackBarModule} from "@angular/material/snack-bar";

@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ComponentsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatProgressSpinnerModule, ReactiveFormsModule, RecaptchaModule, MatSnackBarModule],
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit{
@ViewChild('captcha') captcha: RecaptchaComponent;
protected forgotPassword = false;
protected loggingIn = false;
protected loginError?: string;
loginForm: FormGroup;
protected userAskedForResetLink = false;
protected resetPasswordForm: FormGroup;
protected resetPasswordSuccess: boolean|undefined = undefined;
protected recaptchaIdSiteKey = environment.recaptchaIdentitySiteKey;
private targetUrl: URL = new URL(environment.donateGlobalUriPrefix);

constructor(
private formBuilder: FormBuilder,
private identityService: IdentityService,
private activatedRoute: ActivatedRoute,
) {
}

ngOnInit() {
this.loginForm = this.formBuilder.group({
emailAddress: [null, [
Validators.required,
Validators.pattern(EMAIL_REGEXP),
]],
password: [null, [
Validators.required,
Validators.minLength(10),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure it's worth having a minLength validation on the password field here. It's here because I copied this from the login modal. Min length is good when setting a new password, but I think it might be more confusing than helpful when trying to authenticate with an existing password.

The only password validation rule as far as the user is concerned here is that the password has to match what's set on the account. Yes we know that that implies it will be at least 10 characters, but that feels a bit arbitrary to enforce here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right, I don't remember being notified about password length on any login page, but maybe it's good to check length at the form level before checking against db, as we know it can't be right and won't make unnecessary db calls?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we do that for performance reasons then we don't need to tell the user about it, as it just makes things more complicated for them - and if we do it it makes more sense to do it in the Identity server instead of in frontend. I'll leave it here for now but likely change in a later PR.

]],
});

this.resetPasswordForm = this.formBuilder.group({
emailAddress: [null, [
Validators.required,
Validators.pattern(EMAIL_REGEXP),
]],
});

const redirectParam = this.activatedRoute.snapshot.queryParams.r as string|undefined;

// allowed chars in URL to redirect to: a-z, A-Z, 0-9, - _ /

if (redirectParam && ! redirectParam.match(/[^a-zA-Z0-9\-_\/]/)) {
this.targetUrl = new URL(environment.donateGlobalUriPrefix + '/' + redirectParam);
}
}

login(): void {
this.loggingIn = true; // todo - add visible indication of this to page, e.g. spinner and removal of button
this.captcha.reset();
this.captcha.execute();
}

forgotPasswordClicked(): void {
this.forgotPassword = true;
}

resetPasswordClicked(): void {
this.userAskedForResetLink = true;
this.captcha.reset();
this.captcha.execute();
}

captchaError() {
this.loginError = 'Captcha error – please try again';
this.loggingIn = false;
}

captchaReturn(captchaResponse: string): void {
if (captchaResponse === null) {
// We had a code but now don't, e.g. after expiry at 1 minute. In this case
// the trigger wasn't a login click so do nothing. A repeat login attempt will
// re-execute the captcha in `login()`.
return;
}

if (this.loggingIn) {
const credentials: Credentials = {
captcha_code: captchaResponse,
email_address: this.loginForm.value.emailAddress,
raw_password: this.loginForm.value.password,
};

this.identityService.login(credentials).subscribe({
next: (response: { id: string, jwt: string }) => {
// todo - see if we can make login do `saveJWT` internally and delete it here?
this.identityService.saveJWT(response.id, response.jwt);
// assign window.location rather than the more angular-proper way of
// this.router.navigateByUrl('/') because we need to force the main menu to be updated
// to show that we're now logged in.

window.location.href = this.targetUrl.href;
},
error: (error) => {
this.captcha.reset();
const errorDescription = error.error.error.description;
this.loginError = errorDescription || error.message || 'Unknown error';
this.loggingIn = false;
}});
}

else if (this.userAskedForResetLink) {
this.identityService.getResetPasswordToken(this.resetPasswordForm.value.emailAddress, captchaResponse).subscribe({
next: _ => this.resetPasswordSuccess = true,
error: _ => this.resetPasswordSuccess = false,
});
}
}
}
Loading