diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7702f5b168..8ad8525a2e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -16,5 +16,6 @@
"source.fixAll.eslint": false
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
- }
+ },
+ "typescript.tsdk": "node_modules/typescript/lib"
}
diff --git a/package-lock.json b/package-lock.json
index 7fe92f7add..abbb113a1d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10035,8 +10035,7 @@
"date-fns": {
"version": "2.22.1",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.22.1.tgz",
- "integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==",
- "dev": true
+ "integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg=="
},
"dateformat": {
"version": "1.0.12",
@@ -15689,6 +15688,12 @@
"pretty-format": "^26.6.2"
}
},
+ "jest-localstorage-mock": {
+ "version": "2.4.14",
+ "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.14.tgz",
+ "integrity": "sha512-B+Y0y3J4wBOHdmcFSicWmVYMFAZFbJvjs1EfRIzUJRg2UAK+YVVUgTn7/MdjENey5xbBKmraBmKY5EX+x1NJXA==",
+ "dev": true
+ },
"jest-matcher-utils": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz",
diff --git a/package.json b/package.json
index 9476141a41..c7111b314a 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
"cookie-parser": "~1.4.0",
"css-toggle-switch": "^4.1.0",
"csv-string": "^4.0.1",
+ "date-fns": "^2.22.1",
"dedent-js": "~1.0.1",
"ejs": "^3.1.6",
"express": "^4.16.4",
@@ -202,7 +203,6 @@
"coveralls": "^3.1.0",
"css-loader": "^2.1.1",
"csv-parse": "^4.16.0",
- "date-fns": "^2.22.1",
"env-cmd": "^10.1.0",
"eslint": "^7.28.0",
"eslint-config-prettier": "^8.3.0",
@@ -219,6 +219,7 @@
"husky": "^6.0.0",
"jest": "^26.6.3",
"jest-extended": "^0.11.5",
+ "jest-localstorage-mock": "^2.4.14",
"jest-mock-axios": "^4.4.0",
"lint-staged": "^11.0.0",
"maildev": "^1.1.0",
diff --git a/src/public/main.js b/src/public/main.js
index 5b7ad028f0..87cdfc96d5 100644
--- a/src/public/main.js
+++ b/src/public/main.js
@@ -272,9 +272,6 @@ require('./modules/forms/services/mailto.client.factory.js')
require('./modules/users/config/users.client.config.js')
require('./modules/users/config/users.client.routes.js')
-// User services
-require('./modules/users/services/auth.client.service.js')
-
// User controllers
require('./modules/users/controllers/authentication.client.controller.js')
require('./modules/users/controllers/billing.client.controller.js')
diff --git a/src/public/modules/core/componentViews/avatar-dropdown.html b/src/public/modules/core/componentViews/avatar-dropdown.html
index 1571c9c1dc..31b471699d 100644
--- a/src/public/modules/core/componentViews/avatar-dropdown.html
+++ b/src/public/modules/core/componentViews/avatar-dropdown.html
@@ -64,7 +64,7 @@
Logout
diff --git a/src/public/modules/core/components/avatar-dropdown.client.component.js b/src/public/modules/core/components/avatar-dropdown.client.component.js
index d37ff2ed84..250095bef2 100644
--- a/src/public/modules/core/components/avatar-dropdown.client.component.js
+++ b/src/public/modules/core/components/avatar-dropdown.client.component.js
@@ -1,14 +1,17 @@
const get = require('lodash/get')
+const AuthService = require('../../../services/AuthService')
+const UserService = require('../../../services/UserService')
+
angular.module('core').component('avatarDropdownComponent', {
templateUrl: 'modules/core/componentViews/avatar-dropdown.html',
bindings: {},
controller: [
+ '$q',
'$scope',
'$state',
'$uibModal',
'$window',
- 'Auth',
'Toastr',
avatarDropdownController,
],
@@ -16,17 +19,17 @@ angular.module('core').component('avatarDropdownComponent', {
})
function avatarDropdownController(
+ $q,
$scope,
$state,
$uibModal,
$window,
- Auth,
Toastr,
) {
const vm = this
// Preload user with current details, redirect to signin if unable to get user
- vm.user = Auth.getUser() || $state.go('signin')
+ vm.user = UserService.getUserFromLocalStorage() || $state.go('signin')
vm.avatarText = generateAvatarText()
vm.isDropdownHover = false
@@ -37,7 +40,16 @@ function avatarDropdownController(
async function retrieveUser() {
try {
- const trueUser = await Auth.refreshUser()
+ const trueUser = await UserService.fetchUser()
+ .then((user) => {
+ UserService.saveUserToLocalStorage(user)
+ return user
+ })
+ .catch(() => {
+ UserService.clearUserFromLocalStorage()
+ return null
+ })
+
if (!trueUser) {
$state.go('signin')
return
@@ -74,7 +86,20 @@ function avatarDropdownController(
},
)
- vm.signOut = () => Auth.signOut()
+ vm.logout = () => {
+ return $q
+ .when(AuthService.logout())
+ .then(() => {
+ // Clear user and contact banner on logout
+ UserService.clearUserFromLocalStorage()
+ $window.localStorage.removeItem('contactBannerDismissed')
+ // Redirect to landing page
+ $state.go('landing')
+ })
+ .catch((error) => {
+ console.error('sign out failed:', error)
+ })
+ }
vm.openContactNumberModal = () => {
$uibModal
@@ -91,7 +116,7 @@ function avatarDropdownController(
// Update success, update user.
if (returnVal) {
vm.user = returnVal
- Auth.setUser(returnVal)
+ UserService.saveUserToLocalStorage(returnVal)
vm.showExclamation = !returnVal.contact
}
})
diff --git a/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js
index 3ad98f2a8e..f2628145bc 100644
--- a/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js
+++ b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js
@@ -1,3 +1,5 @@
+const UserService = require('../../../services/UserService')
+
angular
.module('core')
.controller('EditContactNumberModalController', [
@@ -8,7 +10,6 @@ angular
'$scope',
'$uibModalInstance',
'$window',
- 'Auth',
'Toastr',
EditContactNumberModalController,
])
@@ -21,7 +22,6 @@ function EditContactNumberModalController(
$scope,
$uibModalInstance,
$window,
- Auth,
Toastr,
) {
const vm = this
@@ -47,7 +47,7 @@ function EditContactNumberModalController(
vm.VERIFY_STATE = VERIFY_STATE
// Redirect to signin if unable to get user
- vm.user = Auth.getUser() || $state.go('signin')
+ vm.user = UserService.getUserFromLocalStorage() || $state.go('signin')
vm.vfnState = vm.user.contact ? VERIFY_STATE.SUCCESS : VERIFY_STATE.IDLE
diff --git a/src/public/modules/core/services/gtag.client.service.js b/src/public/modules/core/services/gtag.client.service.js
index 632ff5b1bb..d468f221ee 100644
--- a/src/public/modules/core/services/gtag.client.service.js
+++ b/src/public/modules/core/services/gtag.client.service.js
@@ -1,12 +1,14 @@
-angular.module('core').factory('GTag', ['Auth', '$rootScope', '$window', GTag])
+const UserService = require('../../../services/UserService')
-function GTag(Auth, $rootScope, $window) {
+angular.module('core').factory('GTag', ['$rootScope', '$window', GTag])
+
+function GTag($rootScope, $window) {
// Google Analytics tracking ID provided on signup.
const GATrackingID = $window.GATrackingID
let gtagService = {}
const getUserEmail = () => {
- const user = Auth.getUser()
+ const user = UserService.getUserFromLocalStorage()
return user && user.email
}
diff --git a/src/public/modules/forms/admin/components/share-form.client.component.js b/src/public/modules/forms/admin/components/share-form.client.component.js
index 8f06d5d00b..cbd8abd34e 100644
--- a/src/public/modules/forms/admin/components/share-form.client.component.js
+++ b/src/public/modules/forms/admin/components/share-form.client.component.js
@@ -1,5 +1,7 @@
'use strict'
-let dedent = require('dedent-js')
+const dedent = require('dedent-js')
+
+const UserService = require('../../../../services/UserService')
angular.module('forms').component('shareFormComponent', {
templateUrl: 'modules/forms/admin/componentViews/share-form.client.view.html',
@@ -9,11 +11,11 @@ angular.module('forms').component('shareFormComponent', {
status: '<',
userCanEdit: '<',
},
- controller: ['$state', '$translate', 'Auth', shareFormController],
+ controller: ['$state', '$translate', shareFormController],
controllerAs: 'vm',
})
-function shareFormController($state, $translate, Auth) {
+function shareFormController($state, $translate) {
const vm = this
vm.$onInit = () => {
@@ -54,6 +56,6 @@ function shareFormController($state, $translate, Auth) {
}
// Show different pro tip for user depending on their email
- const { email } = Auth.getUser()
- vm.isGovOfficer = String(email).endsWith('.gov.sg')
+ const user = UserService.getUserFromLocalStorage()
+ vm.isGovOfficer = user && String(user.email).endsWith('.gov.sg')
}
diff --git a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js
index 51f148f7b9..9efd6f5912 100644
--- a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js
+++ b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js
@@ -4,6 +4,7 @@ const { StatusCodes } = require('http-status-codes')
const get = require('lodash/get')
const { LogicType } = require('../../../../../types')
const UpdateFormService = require('../../../../services/UpdateFormService')
+const UserService = require('../../../../services/UserService')
const FieldFactory = require('../../helpers/field-factory')
const { UPDATE_FORM_TYPES } = require('../constants/update-form-types')
@@ -42,7 +43,6 @@ angular
'$uibModal',
'FormData',
'FormFields',
- 'Auth',
'moment',
'Toastr',
'$state',
@@ -58,7 +58,6 @@ function AdminFormController(
$uibModal,
FormData,
FormFields,
- Auth,
moment,
Toastr,
$state,
@@ -71,7 +70,7 @@ function AdminFormController(
// Redirect to signin if unable to get user
$scope.user =
- Auth.getUser() ||
+ UserService.getUserFromLocalStorage() ||
$state.go(
'signin',
{
diff --git a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js
index 997c3ed742..2ffef32d71 100644
--- a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js
+++ b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js
@@ -4,6 +4,8 @@ const axios = require('axios').default
const values = require('lodash/values')
const cloneDeep = require('lodash/cloneDeep')
+const UserService = require('../../../../services/UserService')
+
const {
VALID_UPLOAD_FILE_TYPES,
MAX_UPLOAD_FILE_SIZE,
@@ -28,7 +30,6 @@ angular
'Attachment',
'FormFields',
'$q',
- 'Auth',
'$state',
'Toastr',
EditFieldsModalController,
@@ -42,7 +43,6 @@ function EditFieldsModalController(
Attachment,
FormFields,
$q,
- Auth,
$state,
Toastr,
) {
@@ -66,7 +66,7 @@ function EditFieldsModalController(
}
// Serialize allowed email domains
- vm.user = Auth.getUser() || $state.go('signin')
+ vm.user = UserService.getUserFromLocalStorage() || $state.go('signin')
if (vm.field.fieldType === 'email') {
const userEmailDomain = '@' + vm.user.email.split('@').pop()
diff --git a/src/public/modules/forms/admin/controllers/list-forms.client.controller.js b/src/public/modules/forms/admin/controllers/list-forms.client.controller.js
index f9ed175d8f..ece7565ded 100644
--- a/src/public/modules/forms/admin/controllers/list-forms.client.controller.js
+++ b/src/public/modules/forms/admin/controllers/list-forms.client.controller.js
@@ -3,6 +3,8 @@
const get = require('lodash/get')
const BetaService = require('../../../../services/BetaService')
+const UserService = require('../../../../services/UserService')
+
// Forms controller
angular
.module('forms')
@@ -10,7 +12,6 @@ angular
'$scope',
'FormApi',
'$uibModal',
- 'Auth',
'moment',
'$state',
'$timeout',
@@ -24,7 +25,6 @@ function ListFormsController(
$scope,
FormApi,
$uibModal,
- Auth,
moment,
$state,
$timeout,
@@ -41,7 +41,7 @@ function ListFormsController(
// Duplicated form outline on newly dup forms
vm.duplicatedForms = []
// Redirect to signin if unable to get user
- vm.user = Auth.getUser() || $state.go('signin')
+ vm.user = UserService.getUserFromLocalStorage() || $state.go('signin')
// Brings user to edit form page
vm.editForm = function (form) {
diff --git a/src/public/modules/forms/admin/directives/settings-form.client.directive.js b/src/public/modules/forms/admin/directives/settings-form.client.directive.js
index da44a48570..8e2826fbc9 100644
--- a/src/public/modules/forms/admin/directives/settings-form.client.directive.js
+++ b/src/public/modules/forms/admin/directives/settings-form.client.directive.js
@@ -3,6 +3,8 @@ const dedent = require('dedent-js')
const { get, set, isEqual } = require('lodash')
const AdminSubmissionsService = require('../../../../services/AdminSubmissionsService')
+const UserService = require('../../../../services/UserService')
+
const SETTINGS_PATH = [
'title',
'emails',
@@ -35,8 +37,6 @@ angular
'$timeout',
'responseModeEnum',
'$uibModal',
- 'Auth',
- 'Submissions',
settingsFormDirective,
])
@@ -46,7 +46,6 @@ function settingsFormDirective(
$timeout,
responseModeEnum,
$uibModal,
- Auth,
) {
return {
templateUrl:
@@ -59,7 +58,7 @@ function settingsFormDirective(
controller: [
'$scope',
function ($scope) {
- $scope.user = Auth.getUser()
+ $scope.user = UserService.getUserFromLocalStorage()
$scope.responseModeEnum = responseModeEnum
$scope.tempForm = createTempSettings($scope.myform)
diff --git a/src/public/modules/forms/config/forms.client.routes.js b/src/public/modules/forms/config/forms.client.routes.js
index 220e2ece7f..07bb491756 100644
--- a/src/public/modules/forms/config/forms.client.routes.js
+++ b/src/public/modules/forms/config/forms.client.routes.js
@@ -1,6 +1,7 @@
'use strict'
const ExamplesService = require('../../../services/ExamplesService')
+const UserService = require('../../../services/UserService')
// Setting up route
angular.module('forms').config([
@@ -74,16 +75,14 @@ angular.module('forms').config([
url: '/{formId:[0-9a-fA-F]{24}}/use-template',
templateUrl: 'modules/users/views/examples.client.view.html',
resolve: {
- Auth: 'Auth',
FormErrorService: 'FormErrorService',
// If the user is logged in, this field will contain the form data of the provided formId,
// otherwise it will only contain the formId itself.
FormData: [
- 'Auth',
'FormErrorService',
'$stateParams',
- function (Auth, FormErrorService, $stateParams) {
- if (!Auth.getUser()) {
+ function (FormErrorService, $stateParams) {
+ if (!UserService.getUserFromLocalStorage()) {
return $stateParams.formId
}
diff --git a/src/public/modules/users/controllers/authentication.client.controller.js b/src/public/modules/users/controllers/authentication.client.controller.js
index 4ac92def2b..9fb498d2a1 100755
--- a/src/public/modules/users/controllers/authentication.client.controller.js
+++ b/src/public/modules/users/controllers/authentication.client.controller.js
@@ -1,9 +1,10 @@
'use strict'
-const HttpStatus = require('http-status-codes')
+const { StatusCodes } = require('http-status-codes')
const get = require('lodash/get')
const validator = require('validator').default
const AuthService = require('../../../services/AuthService')
+const UserService = require('../../../services/UserService')
angular
.module('users')
@@ -13,20 +14,11 @@ angular
'$state',
'$timeout',
'$window',
- 'Auth',
'GTag',
AuthenticationController,
])
-function AuthenticationController(
- $q,
- $scope,
- $state,
- $timeout,
- $window,
- Auth,
- GTag,
-) {
+function AuthenticationController($q, $scope, $state, $timeout, $window, GTag) {
const vm = this
let notifDelayTimeout
@@ -72,7 +64,7 @@ function AuthenticationController(
* Redirects user with active session to target page
*/
vm.redirectIfActiveSession = function () {
- if (Auth.getUser()) {
+ if (UserService.getUserFromLocalStorage()) {
$state.go($state.params.targetState)
}
}
@@ -216,8 +208,14 @@ function AuthenticationController(
*/
vm.verifyOtp = function () {
vm.buttonClicked = true
- Auth.verifyOtp(vm.credentials).then(
- function () {
+ $q.when(
+ AuthService.verifyLoginOtp({
+ otp: vm.credentials.otp,
+ email: vm.credentials.email,
+ }),
+ )
+ .then((user) => {
+ UserService.saveUserToLocalStorage(user)
vm.buttonClicked = false
// Configure message to be show
vm.signInMsg = {
@@ -239,25 +237,32 @@ function AuthenticationController(
}, 500)
GTag.loginSuccess('otp')
- },
- function (error) {
+ })
+ .catch((error) => {
+ let errorMsg = get(
+ error,
+ 'response.data',
+ 'Failed to send login OTP. Please try again later and if the problem persists, contact us.',
+ )
+ const errorStatus = get(error, 'response.status')
+ if (errorStatus >= StatusCodes.INTERNAL_SERVER_ERROR) {
+ errorMsg = 'An unknown error occurred'
+ }
+
vm.buttonClicked = false
// Configure message to be show
vm.signInMsg = {
isMsg: true,
isError: true,
- msg:
- error.status >= HttpStatus.INTERNAL_SERVER_ERROR
- ? 'An unknown error occurred'
- : error.data,
+ msg: errorMsg,
}
$timeout(function () {
angular.element('#otp-input').focus()
angular.element('#otp-input').select()
}, 100)
- GTag.loginFailure('otp', String(error.status || error.data || ''))
- },
- )
+
+ GTag.loginFailure('otp', String(errorStatus || errorMsg))
+ })
}
const cancelNotifDelayTimeout = () => {
diff --git a/src/public/modules/users/controllers/billing.client.controller.js b/src/public/modules/users/controllers/billing.client.controller.js
index 5a580bcfbf..21a6972568 100644
--- a/src/public/modules/users/controllers/billing.client.controller.js
+++ b/src/public/modules/users/controllers/billing.client.controller.js
@@ -2,6 +2,7 @@
const { CsvGenerator } = require('../../forms/helpers/CsvGenerator')
const BillingService = require('../../../services/BillingService')
+const UserService = require('../../../services/UserService')
angular
.module('users')
@@ -9,15 +10,14 @@ angular
'$q',
'$state',
'$timeout',
- 'Auth',
'NgTableParams',
BillingController,
])
-function BillingController($q, $state, $timeout, Auth, NgTableParams) {
+function BillingController($q, $state, $timeout, NgTableParams) {
const vm = this
- vm.user = Auth.getUser()
+ vm.user = UserService.getUserFromLocalStorage()
// Send non-logged in personnel to sign in page
if (!vm.user) {
diff --git a/src/public/modules/users/controllers/examples-card.client.directive.js b/src/public/modules/users/controllers/examples-card.client.directive.js
index d0f9faf37a..e2897490bb 100644
--- a/src/public/modules/users/controllers/examples-card.client.directive.js
+++ b/src/public/modules/users/controllers/examples-card.client.directive.js
@@ -1,6 +1,8 @@
'use strict'
const BetaService = require('../../../services/BetaService')
+const UserService = require('../../../services/UserService')
+
angular.module('users').directive('examplesCard', [examplesCard])
function examplesCard() {
@@ -20,7 +22,6 @@ function examplesCard() {
'$uibModal',
'$state',
'GTag',
- 'Auth',
'$location',
'Toastr',
'$q',
@@ -36,12 +37,11 @@ function examplesCardController(
$uibModal,
$state,
GTag,
- Auth,
$location,
Toastr,
$q,
) {
- $scope.user = Auth.getUser()
+ $scope.user = UserService.getUserFromLocalStorage()
$scope.openTemplateModal = () => {
$scope.templateUrl = $state.href(
diff --git a/src/public/modules/users/controllers/examples-list.client.controller.js b/src/public/modules/users/controllers/examples-list.client.controller.js
index c1a6c6047c..5569241b41 100644
--- a/src/public/modules/users/controllers/examples-list.client.controller.js
+++ b/src/public/modules/users/controllers/examples-list.client.controller.js
@@ -1,13 +1,13 @@
'use strict'
const ExamplesService = require('../../../services/ExamplesService')
+const UserService = require('../../../services/UserService')
const PAGE_SIZE = 16
angular.module('users').controller('ExamplesController', [
'$q',
'$scope',
- 'Auth',
'GTag',
'$state',
'$timeout',
@@ -18,18 +18,10 @@ angular.module('users').controller('ExamplesController', [
ExamplesController,
])
-function ExamplesController(
- $q,
- $scope,
- Auth,
- GTag,
- $state,
- $timeout,
- FormData,
-) {
+function ExamplesController($q, $scope, GTag, $state, $timeout, FormData) {
const vm = this
- vm.user = Auth.getUser()
+ vm.user = UserService.getUserFromLocalStorage()
// Send non-logged in personnel to sign in page
if (!vm.user) {
diff --git a/src/public/modules/users/services/auth.client.service.js b/src/public/modules/users/services/auth.client.service.js
deleted file mode 100644
index a5bf7f719e..0000000000
--- a/src/public/modules/users/services/auth.client.service.js
+++ /dev/null
@@ -1,94 +0,0 @@
-'use strict'
-
-angular
- .module('users')
- .factory('Auth', ['$q', '$http', '$state', '$window', Auth])
-
-function Auth($q, $http, $state, $window) {
- /**
- * Object representing a logged-in user and agency
- * @typedef {Object} User
- * @property {String} _id - The database ID of the user
- * @property {String} email - The email of the user
- * @property {Object} betaFlags - Whether the user has beta privileges for different features
- * @property {Object} agency - The agency that the user belongs to
- * @property {String} agency.created - Date created
- * @property {Array} agency.emailDomain - Email domains belonging to agency
- * @property {String} agency.fullName - The full name of the agency
- * @property {String} agency.shortName - The short name of the agency
- * @property {String} agency.logo - URI specifying location of agency logo
- * @property {String} agency._id - The database ID of the agency
- */
-
- let authService = {
- verifyOtp,
- getUser,
- setUser,
- refreshUser,
- signOut,
- }
- return authService
-
- /**
- * User setter function
- * @param {User} user
- */
- function setUser(user) {
- $window.localStorage.setItem('user', JSON.stringify(user))
- }
-
- /**
- * User getter function
- * @returns {User} user
- */
- function getUser() {
- // TODO: For backwards compatibility in case user is still logged in
- // and details are still saved on window
- let user = $window.localStorage.getItem('user')
- try {
- return user && JSON.parse(user)
- } catch (error) {
- return null
- }
- }
-
- function refreshUser() {
- return $http
- .get('/api/v3/user')
- .then(({ data }) => {
- setUser(data)
- return data
- })
- .catch(() => {
- setUser(null)
- return null
- })
- }
- function verifyOtp(credentials) {
- let deferred = $q.defer()
- $http.post('/api/v3/auth/otp/verify', credentials).then(
- function (response) {
- setUser(response.data)
- deferred.resolve()
- },
- function (error) {
- deferred.reject(error)
- },
- )
- return deferred.promise
- }
-
- function signOut() {
- $http.get('/api/v3/auth/logout').then(
- function () {
- $window.localStorage.removeItem('user')
- // Clear contact banner on logout
- $window.localStorage.removeItem('contactBannerDismissed')
- $state.go('landing')
- },
- function (error) {
- console.error('sign out failed:', error)
- },
- )
- }
-}
diff --git a/src/public/services/AuthService.ts b/src/public/services/AuthService.ts
index c08e1c5ccf..0dbdd7477a 100644
--- a/src/public/services/AuthService.ts
+++ b/src/public/services/AuthService.ts
@@ -1,6 +1,8 @@
import axios from 'axios'
import { Opaque } from 'type-fest'
+import { User } from '../../types/api/user'
+
// Exported for testing.
export const AUTH_ENDPOINT = '/api/v3/auth'
@@ -31,3 +33,23 @@ export const sendLoginOtp = async (email: Email): Promise => {
})
.then(({ data }) => data)
}
+
+/**
+ * Verifies the login OTP and returns the user if OTP is valid.
+ * @param params.email the email to verify
+ * @param params.otp the OTP sent to the given email to verify
+ * @returns logged in user when successful
+ * @throws Error on non 2xx response
+ */
+export const verifyLoginOtp = async (params: {
+ otp: string
+ email: string
+}): Promise => {
+ return axios
+ .post(`${AUTH_ENDPOINT}/otp/verify`, params)
+ .then(({ data }) => data)
+}
+
+export const logout = async (): Promise => {
+ return axios.get(`${AUTH_ENDPOINT}/logout`)
+}
diff --git a/src/public/services/UserService.ts b/src/public/services/UserService.ts
new file mode 100644
index 0000000000..2cda68d0d0
--- /dev/null
+++ b/src/public/services/UserService.ts
@@ -0,0 +1,58 @@
+import axios from 'axios'
+
+import { User } from '../../types/api/user'
+
+/** Exported for testing */
+export const STORAGE_USER_KEY = 'user'
+/** Exported for testing */
+export const USER_ENDPOINT = '/api/v3/user'
+
+/**
+ * Get logged in user from localStorage.
+ * May not be needed in React depending on implementation.
+ *
+ * @returns user if available, null otherwise
+ */
+export const getUserFromLocalStorage = (): User | null => {
+ const userStringified = localStorage.getItem(STORAGE_USER_KEY)
+
+ if (userStringified) {
+ try {
+ return User.parse(JSON.parse(userStringified))
+ } catch (error) {
+ // Invalid shape, clear from storage.
+ clearUserFromLocalStorage()
+ return null
+ }
+ }
+ return null
+}
+
+/**
+ * Save logged in user to localStorage.
+ * May not be needed in React depending on implementation.
+ *
+ * @param user the user to save to local storage
+ */
+export const saveUserToLocalStorage = (user: User): void => {
+ localStorage.setItem(STORAGE_USER_KEY, JSON.stringify(user))
+}
+
+/**
+ * Clear logged in user from localStorage.
+ * May not even be needed in React depending on implementation.
+ */
+export const clearUserFromLocalStorage = (): void => {
+ localStorage.removeItem(STORAGE_USER_KEY)
+}
+
+/**
+ * Fetches the user from the server using the current session cookie.
+ *
+ * Side effect: On success, save the retrieved user to localStorage.
+ * Side effect: On error, clear the user (if any) from localStorage.
+ * @returns the logged in user if session is valid, `null` otherwise
+ */
+export const fetchUser = async (): Promise => {
+ return axios.get(USER_ENDPOINT).then(({ data }) => data)
+}
diff --git a/src/public/services/__tests__/AuthService.test.ts b/src/public/services/__tests__/AuthService.test.ts
index 7ce4b2053e..2969cb045a 100644
--- a/src/public/services/__tests__/AuthService.test.ts
+++ b/src/public/services/__tests__/AuthService.test.ts
@@ -5,11 +5,12 @@ import { Opaque } from 'type-fest'
import {
AUTH_ENDPOINT,
checkIsEmailAllowed,
+ logout,
sendLoginOtp,
+ verifyLoginOtp,
} from '../AuthService'
jest.mock('axios')
-
const MockAxios = mocked(axios, true)
// Duplicated here instead of exporting from AuthService to prevent production
@@ -57,4 +58,46 @@ describe('AuthService', () => {
})
})
})
+
+ describe('verifyLoginOtp', () => {
+ const EXPECTED_POST_ENDPOINT = `${AUTH_ENDPOINT}/otp/verify`
+ const MOCK_OTP = '123456'
+ const MOCK_EMAIL = 'mockEmail@example.com'
+
+ it('should return user on success', async () => {
+ // Arrange
+ const mockUser = {
+ _id: 'some id',
+ email: MOCK_EMAIL,
+ }
+ MockAxios.post.mockResolvedValueOnce({ data: mockUser })
+ const expectedParams = { otp: MOCK_OTP, email: MOCK_EMAIL }
+
+ // Act
+ const actual = await verifyLoginOtp(expectedParams)
+
+ // Assert
+ expect(actual).toEqual(mockUser)
+ expect(MockAxios.post).toHaveBeenCalledWith(
+ EXPECTED_POST_ENDPOINT,
+ expectedParams,
+ )
+ })
+ })
+
+ describe('logout', () => {
+ const EXPECTED_ENDPOINT = `${AUTH_ENDPOINT}/logout`
+ it('should call endpoint successfully', async () => {
+ // Arrange
+ const mockReturn = { status: 200 }
+ MockAxios.get.mockResolvedValueOnce(mockReturn)
+
+ // Act
+ const actual = await logout()
+
+ // Assert
+ expect(actual).toEqual(mockReturn)
+ expect(MockAxios.get).toHaveBeenLastCalledWith(EXPECTED_ENDPOINT)
+ })
+ })
})
diff --git a/src/public/services/__tests__/UserService.test.ts b/src/public/services/__tests__/UserService.test.ts
new file mode 100644
index 0000000000..e4097cf7aa
--- /dev/null
+++ b/src/public/services/__tests__/UserService.test.ts
@@ -0,0 +1,113 @@
+import axios from 'axios'
+import { mocked } from 'ts-jest/utils'
+
+import { User } from '../../../types/api/user'
+import * as UserService from '../UserService'
+
+const { STORAGE_USER_KEY, USER_ENDPOINT } = UserService
+
+jest.mock('axios')
+
+const MockAxios = mocked(axios, true)
+
+describe('UserService', () => {
+ const MOCK_USER: User = {
+ _id: 'some id' as User['_id'],
+ email: 'mock@example.com',
+ agency: {
+ _id: 'some agency id' as User['agency']['_id'],
+ emailDomain: ['example.com'],
+ fullName: 'Example Agency',
+ logo: 'path/to/agency/logo',
+ shortName: 'e',
+ },
+ created: 'some created date',
+ lastAccessed: 'some last accessed date',
+ updatedAt: 'some last updated at date',
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('getUserFromLocalStorage', () => {
+ it('should return user from localStorage when valid user exists', async () => {
+ // Arrange
+ localStorage.setItem(STORAGE_USER_KEY, JSON.stringify(MOCK_USER))
+
+ // Act
+ const actual = UserService.getUserFromLocalStorage()
+
+ // Assert
+ expect(actual).toEqual(MOCK_USER)
+ expect(localStorage.getItem).toHaveBeenLastCalledWith(STORAGE_USER_KEY)
+ })
+
+ it('should return null and clear user from localStorage when user is an invalid shape', async () => {
+ // Arrange
+ localStorage.setItem(
+ STORAGE_USER_KEY,
+ 'this string is obviously not a well formed user',
+ )
+
+ // Act
+ const actual = UserService.getUserFromLocalStorage()
+
+ // Assert
+ expect(localStorage.getItem).toHaveBeenLastCalledWith(STORAGE_USER_KEY)
+ expect(actual).toEqual(null)
+ expect(localStorage.removeItem).toHaveBeenLastCalledWith(STORAGE_USER_KEY)
+ })
+
+ it('should return null and clear user from localStorage when JSON.parse throws', async () => {
+ // Arrange
+ localStorage.setItem(STORAGE_USER_KEY, '{ invalid JSON string')
+
+ // Act
+ const actual = UserService.getUserFromLocalStorage()
+
+ // Assert
+ expect(localStorage.getItem).toHaveBeenLastCalledWith(STORAGE_USER_KEY)
+ expect(actual).toEqual(null)
+ expect(localStorage.removeItem).toHaveBeenLastCalledWith(STORAGE_USER_KEY)
+ })
+ })
+
+ describe('saveUserToLocalStorage', () => {
+ it('should successfully save given user to localStorage', () => {
+ // Act
+ UserService.saveUserToLocalStorage(MOCK_USER)
+
+ // Assert
+ expect(localStorage.setItem).toHaveBeenLastCalledWith(
+ STORAGE_USER_KEY,
+ // Should be stringified.
+ JSON.stringify(MOCK_USER),
+ )
+ })
+ })
+
+ describe('clearUserFromLocalStorage', () => {
+ it('should successfully clear user from localStorage', () => {
+ // Act
+ UserService.clearUserFromLocalStorage()
+
+ // Assert
+ expect(localStorage.removeItem).toHaveBeenLastCalledWith(STORAGE_USER_KEY)
+ })
+ })
+
+ describe('fetchUser', () => {
+ it('should return user successfully', async () => {
+ // Arrange
+ MockAxios.get.mockResolvedValueOnce({ data: MOCK_USER })
+
+ // Act
+ const actual = await UserService.fetchUser()
+
+ // Assert
+ expect(actual).toEqual(MOCK_USER)
+ expect(MockAxios.get).toHaveBeenLastCalledWith(USER_ENDPOINT)
+ })
+ })
+})
diff --git a/src/types/api/agency.ts b/src/types/api/agency.ts
new file mode 100644
index 0000000000..d65a809515
--- /dev/null
+++ b/src/types/api/agency.ts
@@ -0,0 +1,14 @@
+import { Opaque } from 'type-fest'
+import { z } from 'zod'
+
+type AgencyId = Opaque
+
+export const Agency = z.object({
+ emailDomain: z.array(z.string()),
+ fullName: z.string(),
+ shortName: z.string(),
+ logo: z.string(),
+ _id: z.string() as unknown as z.Schema,
+})
+
+export type Agency = z.infer
diff --git a/src/types/api/user.ts b/src/types/api/user.ts
new file mode 100644
index 0000000000..25b589f30d
--- /dev/null
+++ b/src/types/api/user.ts
@@ -0,0 +1,25 @@
+import { isDate, parseISO } from 'date-fns'
+import { Opaque } from 'type-fest'
+import { z } from 'zod'
+
+import { Agency } from './agency'
+
+type UserId = Opaque
+type UserContact = Opaque
+
+const DateString = z.string().refine(
+ (val) => isDate(parseISO(val)),
+ (val) => ({ message: `${val} is not a valid date` }),
+)
+
+export const User = z.object({
+ _id: z.string() as unknown as z.Schema,
+ email: z.string().email(),
+ agency: Agency,
+ created: DateString,
+ lastAccessed: DateString,
+ updatedAt: DateString,
+ contact: (z.string() as unknown as z.Schema).optional(),
+})
+
+export type User = z.infer
diff --git a/tests/unit/frontend/jest.config.js b/tests/unit/frontend/jest.config.js
index 17526446ae..6294e2c5aa 100644
--- a/tests/unit/frontend/jest.config.js
+++ b/tests/unit/frontend/jest.config.js
@@ -2,6 +2,7 @@ module.exports = {
preset: 'ts-jest/presets/js-with-ts',
rootDir: '../../../',
modulePaths: [''],
+ testEnvironment: 'jsdom',
roots: ['/src/public/', '/tests/unit/frontend/'],
globals: {
// Revert when memory leak in ts-jest is fixed.
@@ -12,4 +13,5 @@ module.exports = {
},
clearMocks: true,
setupFilesAfterEnv: ['jest-extended'],
+ setupFiles: ['jest-localstorage-mock'],
}