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

refactor(auth.client): (1) extract email validation and send login otp flow to Typescript #2084

Merged
merged 11 commits into from
Jun 9, 2021
Merged
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
'use strict'

const HttpStatus = require('http-status-codes')
const get = require('lodash/get')
const validator = require('validator').default
const AuthService = require('../../../services/AuthService')

angular
.module('users')
.controller('AuthenticationController', [
'$q',
'$scope',
'$state',
'$timeout',
Expand All @@ -15,6 +19,7 @@ angular
])

function AuthenticationController(
$q,
$scope,
$state,
$timeout,
Expand Down Expand Up @@ -54,7 +59,7 @@ function AuthenticationController(

vm.handleEmailKeyUp = function (e) {
if (e.keyCode == 13) {
vm.isSubmitEmailDisabled === false && vm.checkEmail()
vm.isSubmitEmailDisabled === false && vm.login()
// condition vm.isSubmitEmailDisabled == false is necessary to prevent retries using enter key
// when submit button is disabled
} else {
Expand Down Expand Up @@ -115,52 +120,45 @@ function AuthenticationController(
}
}

const setEmailSignInError = (errorMsg) => {
vm.buttonClicked = false
vm.signInMsg = {
isMsg: true,
isError: true,
msg: errorMsg,
}
vm.isSubmitEmailDisabled = true
}

// Steps of sign-in process
// 1 - Check if user email is in valid format
// 2 - Check if user's email domain (i.e. agency) is valid
// 3 - Send OTP to user email
// 4 - Verify OTP

/**
* Conducts front-end check of user email format
* Checks validity of email domain (i.e. agency) and sends login OTP if email
* is valid.
*/
vm.checkEmail = function () {
vm.login = function () {
vm.buttonClicked = true
if (/\S+@\S+\.\S+/.test(vm.credentials.email)) {
vm.credentials.email = vm.credentials.email.toLowerCase()
vm.checkUser()
} else {
// Invalid email
vm.buttonClicked = false
vm.signInMsg = {
isMsg: true,
isError: true,
msg: 'Please enter a valid email',
}
vm.isSubmitEmailDisabled = true

const email = vm.credentials.email
if (!validator.isEmail(email)) {
setEmailSignInError('Please enter a valid email address')
return
}
}

/**
* Checks validity of email domain (i.e. agency)
* Directs user to otp input page
*/
vm.checkUser = function () {
Auth.checkUser(vm.credentials).then(
function () {
vm.sendOtp()
},
function (error) {
// Invalid agency
vm.buttonClicked = false
vm.signInMsg = {
isMsg: true,
isError: true,
msg: error,
}
vm.isSubmitEmailDisabled = true
},
)
$q.when(AuthService.checkIsEmailAllowed(vm.credentials.email))
.then(() => vm.sendOtp())
.catch((error) => {
const errorMsg = get(
error,
'response.data',
'Something went wrong while validating your email. Please refresh and try again',
)
setEmailSignInError(errorMsg)
})
}

/**
Expand All @@ -173,9 +171,9 @@ function AuthenticationController(
vm.isOtpSending = true
vm.buttonClicked = true
vm.showOtpDelayNotification = false
const { email } = vm.credentials
Auth.sendOtp({ email }).then(
function (success) {

$q.when(AuthService.sendLoginOtp(vm.credentials.email))
.then((success) => {
vm.isOtpSending = false
vm.buttonClicked = false
// Configure message to be show
Expand All @@ -195,21 +193,22 @@ function AuthenticationController(
vm.signInMsg.isMsg = false
vm.showOtpDelayNotification = true
}, 20000)
},
function (error) {
})
.catch((error) => {
const errorMsg = get(
error,
'response.data.message',
'Failed to send login OTP. Please try again later and if the problem persists, contact us.',
)
vm.isOtpSending = false
vm.buttonClicked = false
// Configure message to be shown
const msg =
(error && error.data && error.data.message) ||
'Failed to send login OTP. Please try again later and if the problem persists, contact us.'
vm.signInMsg = {
isMsg: true,
isError: true,
msg,
msg: errorMsg,
}
},
)
})
}

/**
Expand Down
29 changes: 0 additions & 29 deletions src/public/modules/users/services/auth.client.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ function Auth($q, $http, $state, $window) {
*/

let authService = {
checkUser,
sendOtp,
verifyOtp,
getUser,
setUser,
Expand Down Expand Up @@ -66,33 +64,6 @@ function Auth($q, $http, $state, $window) {
return null
})
}

function checkUser(credentials) {
let deferred = $q.defer()
$http.post('/api/v3/auth/email/validate', credentials).then(
function (response) {
deferred.resolve(response.data)
},
function (error) {
deferred.reject(error.data)
},
)
return deferred.promise
}

function sendOtp(credentials) {
let deferred = $q.defer()
$http.post('/api/v3/auth/otp/generate', credentials).then(
function (response) {
deferred.resolve(response.data)
},
function (error) {
deferred.reject(error)
},
)
return deferred.promise
}

function verifyOtp(credentials) {
let deferred = $q.defer()
$http.post('/api/v3/auth/otp/verify', credentials).then(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
<div class="btn-grp">
<button
class="btn-custom btn-medium"
ng-click="vm.checkEmail()"
ng-click="vm.login()"
ng-disabled="vm.buttonClicked || vm.isSubmitEmailDisabled"
>
Get Started
Expand Down
35 changes: 35 additions & 0 deletions src/public/services/AuthService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import axios from 'axios'
import { Opaque } from 'type-fest'

// Exported for testing.
export const AUTH_ENDPOINT = '/api/v3/auth'

type Email = Opaque<string, 'Email'>

/**
* Check whether the given email string is from an email domain.
karrui marked this conversation as resolved.
Show resolved Hide resolved
* @param email the email to check
* @returns original email if email is valid
*/
export const checkIsEmailAllowed = async (email: string): Promise<Email> => {
return axios
.post(`${AUTH_ENDPOINT}/email/validate`, {
email: email.toLowerCase(),
})
.then(() => email as Email)
}

/**
* Sends login OTP to given email
* @param email email to send login OTP to
* @returns success string if login OTP is sent successfully
* @throws Error on invalid email
* @throws Error on non 2xx response
karrui marked this conversation as resolved.
Show resolved Hide resolved
*/
export const sendLoginOtp = async (email: Email): Promise<string> => {
return axios
.post<string>(`${AUTH_ENDPOINT}/otp/generate`, {
email: email.toLowerCase(),
})
.then(({ data }) => data)
}
60 changes: 60 additions & 0 deletions src/public/services/__tests__/AuthService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import axios from 'axios'
import { mocked } from 'ts-jest/utils'
import { Opaque } from 'type-fest'

import {
AUTH_ENDPOINT,
checkIsEmailAllowed,
sendLoginOtp,
} from '../AuthService'

jest.mock('axios')

const MockAxios = mocked(axios, true)

// Duplicated here instead of exporting from AuthService to prevent production
// code from casting to Email type without going through a type guard/validator.
type TestEmail = Opaque<string, 'Email'>

describe('AuthService', () => {
describe('checkIsEmailAllowed', () => {
const EXPECTED_POST_ENDPOINT = `${AUTH_ENDPOINT}/email/validate`

it('should return given email argument when email is allowed', async () => {
karrui marked this conversation as resolved.
Show resolved Hide resolved
// Arrange
const mockEmail = '[email protected]'
MockAxios.post.mockResolvedValueOnce({ status: 200 })

// Act
const actual = await checkIsEmailAllowed(mockEmail)

// Assert
expect(actual).toEqual(mockEmail)
expect(MockAxios.post).toHaveBeenCalledWith(EXPECTED_POST_ENDPOINT, {
email: mockEmail.toLowerCase(),
})
})
})

describe('sendLoginOtp', () => {
const EXPECTED_POST_ENDPOINT = `${AUTH_ENDPOINT}/otp/generate`

it('should return success string when OTP is successfully generated', async () => {
karrui marked this conversation as resolved.
Show resolved Hide resolved
// Arrange
const mockEmail = '[email protected]'
const mockSuccessStr = 'yippee ki yay'
MockAxios.post.mockResolvedValueOnce({
data: mockSuccessStr,
})

// Act
const actual = await sendLoginOtp(mockEmail as TestEmail)

// Assert
expect(actual).toEqual(mockSuccessStr)
expect(MockAxios.post).toHaveBeenCalledWith(EXPECTED_POST_ENDPOINT, {
email: mockEmail.toLowerCase(),
})
})
})
})