diff --git a/backend/migrations/20241216140000-addInvalidFindUsernameAttemptsColumnToLoginTable.js b/backend/migrations/20241216140000-addInvalidFindUsernameAttemptsColumnToLoginTable.js new file mode 100644 index 0000000000..375b48e6b5 --- /dev/null +++ b/backend/migrations/20241216140000-addInvalidFindUsernameAttemptsColumnToLoginTable.js @@ -0,0 +1,17 @@ +'use strict'; + +const table = { + tableName: 'Login', + schema: 'cqc', +}; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.addColumn(table, 'InvalidFindUsernameAttempts', { type: Sequelize.DataTypes.INTEGER }); + }, + + async down(queryInterface) { + return queryInterface.removeColumn(table, 'InvalidFindUsernameAttempts'); + }, +}; diff --git a/backend/server/data/constants.js b/backend/server/data/constants.js new file mode 100644 index 0000000000..527b035d87 --- /dev/null +++ b/backend/server/data/constants.js @@ -0,0 +1,9 @@ +const UserAccountStatus = { + Locked: 'Locked', + Pending: 'PENDING', +}; + +const MaxLoginAttempts = 10; +const MaxFindUsernameAttempts = 5; + +module.exports = { UserAccountStatus, MaxLoginAttempts, MaxFindUsernameAttempts }; diff --git a/backend/server/models/login.js b/backend/server/models/login.js index 71631437fe..560d4f5a12 100644 --- a/backend/server/models/login.js +++ b/backend/server/models/login.js @@ -1,5 +1,6 @@ /* jshint indent: 2 */ var bcrypt = require('bcrypt-nodejs'); +const { MaxLoginAttempts, MaxFindUsernameAttempts, UserAccountStatus } = require('../data/constants'); module.exports = function (sequelize, DataTypes) { const Login = sequelize.define( @@ -39,6 +40,11 @@ module.exports = function (sequelize, DataTypes) { allowNull: false, field: '"InvalidAttempt"', }, + invalidFindUsernameAttempts: { + type: DataTypes.INTEGER, + allowNull: true, + field: '"InvalidFindUsernameAttempts"', + }, firstLogin: { type: DataTypes.DATE, allowNull: true, @@ -132,5 +138,47 @@ module.exports = function (sequelize, DataTypes) { }); }; + Login.prototype.recordInvalidFindUsernameAttempts = async function (transaction) { + const loginAccount = await Login.findByPk(this.id); + + const previousAttempts = loginAccount.invalidFindUsernameAttempts ?? 0; + const updatedFields = { + invalidFindUsernameAttempts: previousAttempts + 1, + }; + const options = transaction ? { transaction } : {}; + + return this.update(updatedFields, options); + }; + + Login.prototype.lockAccount = async function (transaction) { + const updatedFields = { + isActive: false, + status: UserAccountStatus.Locked, + }; + + const options = transaction ? { transaction } : {}; + + return this.update(updatedFields, options); + }; + + Login.prototype.unlockAccount = async function (transaction) { + const loginAccount = await Login.findByPk(this.id); + const updatedFields = { + isActive: true, + status: null, + }; + const options = transaction ? { transaction } : {}; + + if (loginAccount.invalidAttempt >= MaxLoginAttempts) { + updatedFields.invalidAttempt = MaxLoginAttempts - 1; + } + + if (loginAccount.invalidFindUsernameAttempts >= MaxFindUsernameAttempts) { + updatedFields.invalidFindUsernameAttempts = MaxFindUsernameAttempts - 1; + } + + return this.update(updatedFields, options); + }; + return Login; }; diff --git a/backend/server/models/user.js b/backend/server/models/user.js index 0cc5ec57ce..4b321e1fd4 100644 --- a/backend/server/models/user.js +++ b/backend/server/models/user.js @@ -1,6 +1,8 @@ /* jshint indent: 2 */ const { Op } = require('sequelize'); +const { padEnd } = require('lodash'); const { sanitise } = require('../utils/db'); +const { UserAccountStatus } = require('../data/constants'); module.exports = function (sequelize, DataTypes) { const User = sequelize.define( @@ -486,5 +488,59 @@ module.exports = function (sequelize, DataTypes) { }); }; + User.findByRelevantInfo = async function ({ name, workplaceId, postcode, email }) { + if (!workplaceId && !postcode) { + return null; + } + + const workplaceIdWithTrailingSpace = padEnd(workplaceId ?? '', 8, ' '); + const establishmentWhereClause = workplaceId + ? { NmdsID: [workplaceId, workplaceIdWithTrailingSpace] } + : { postcode: postcode }; + + const query = { + attributes: ['uid', 'SecurityQuestionValue'], + where: { + Archived: false, + FullNameValue: name, + EmailValue: email, + SecurityQuestionValue: { [Op.ne]: null }, + SecurityQuestionAnswerValue: { [Op.ne]: null }, + }, + include: [ + { + model: sequelize.models.establishment, + where: establishmentWhereClause, + required: true, + attributes: [], + }, + { + model: sequelize.models.login, + required: true, + attributes: ['status'], + }, + ], + raw: true, + }; + + const allFoundUsers = await this.findAll(query); + + if (!allFoundUsers?.length || allFoundUsers.length === 0) { + return null; + } + + if (allFoundUsers.length > 1) { + return { multipleAccountsFound: true }; + } + + const userFound = allFoundUsers[0]; + + if (userFound && userFound['login.status'] === UserAccountStatus.Locked) { + return { accountLocked: true }; + } + + return userFound; + }; + return User; }; diff --git a/backend/server/routes/admin/unlock-account/index.js b/backend/server/routes/admin/unlock-account/index.js index 3acbd0c666..bdb1f8fffb 100644 --- a/backend/server/routes/admin/unlock-account/index.js +++ b/backend/server/routes/admin/unlock-account/index.js @@ -25,11 +25,7 @@ const unlockAccount = async (req, res) => { // Make sure we have the matching user if (login && login.id && username === login.username) { try { - const updateduser = await login.update({ - isActive: true, - invalidAttempt: 9, - status: null, - }); + const updateduser = await login.unlockAccount(); if (updateduser) { res.status(200); return res.json({ status: '0', message: 'User has been set as active' }); diff --git a/backend/server/routes/login.js b/backend/server/routes/login.js index 2e3af18fe3..e3029da265 100644 --- a/backend/server/routes/login.js +++ b/backend/server/routes/login.js @@ -22,6 +22,7 @@ const { adminRoles } = require('../utils/adminUtils'); const sendMail = require('../utils/email/notify-email').sendPasswordReset; const { authLimiter } = require('../utils/middleware/rateLimiting'); +const { MaxLoginAttempts, UserAccountStatus } = require('../data/constants'); const tribalHashCompare = (password, salt, expectedHash) => { const hash = crypto.createHash('sha256'); @@ -197,13 +198,13 @@ router.post('/', async (req, res) => { if (establishmentUser) { //check weather posted user is locked or pending - if (!establishmentUser.isActive && establishmentUser.status === 'Locked') { + if (!establishmentUser.isActive && establishmentUser.status === UserAccountStatus.Locked) { //check for locked status, if locked then return with 409 error console.error('POST .../login failed: User status is locked'); return res.status(409).send({ message: 'Authentication failed.', }); - } else if (!establishmentUser.isActive && establishmentUser.status === 'PENDING') { + } else if (!establishmentUser.isActive && establishmentUser.status === UserAccountStatus.Pending) { //check for Pending status, if Pending then return with 403 error console.error('POST .../login failed: User status is pending'); return res.status(405).send({ @@ -290,6 +291,7 @@ router.post('/', async (req, res) => { // reset the number of failed attempts on any successful login const loginUpdate = { invalidAttempt: 0, + invalidFindUsernameAttempts: 0, lastLogin: new Date(), }; @@ -353,19 +355,17 @@ router.post('/', async (req, res) => { .json(response); } else { await models.sequelize.transaction(async (t) => { - const maxNumberOfFailedAttempts = 10; - // increment the number of failed attempts by one const loginUpdate = { invalidAttempt: establishmentUser.invalidAttempt + 1, }; await establishmentUser.update(loginUpdate, { transaction: t }); - if (establishmentUser.invalidAttempt === maxNumberOfFailedAttempts + 1) { + if (establishmentUser.invalidAttempt === MaxLoginAttempts + 1) { // lock the account const loginUpdate = { isActive: false, - status: 'Locked', + status: UserAccountStatus.Locked, }; await establishmentUser.update(loginUpdate, { transaction: t }); @@ -404,8 +404,7 @@ router.post('/', async (req, res) => { const auditEvent = { userFk: establishmentUser.user.id, username: establishmentUser.username, - type: - establishmentUser.invalidAttempt >= maxNumberOfFailedAttempts + 1 ? 'loginWhileLocked' : 'loginFailed', + type: establishmentUser.invalidAttempt >= MaxLoginAttempts + 1 ? 'loginWhileLocked' : 'loginFailed', property: 'password', event: {}, }; diff --git a/backend/server/routes/registration.js b/backend/server/routes/registration.js index b89229b188..1714bc3535 100644 --- a/backend/server/routes/registration.js +++ b/backend/server/routes/registration.js @@ -1,7 +1,6 @@ const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); -uuidv4(); const isLocal = require('../utils/security/isLocalTest').isLocal; const { registerAccount } = require('./registration/registerAccount'); const models = require('../models'); @@ -9,6 +8,8 @@ const models = require('../models'); const generateJWT = require('../utils/security/generateJWT'); const sendMail = require('../utils/email/notify-email').sendPasswordReset; const { authLimiter } = require('../utils/middleware/rateLimiting'); +const { findUserAccount } = require('./registration/findUserAccount'); +const { findUsername } = require('./registration/findUsername'); router.use('/establishmentExistsCheck', require('./registration/establishmentExistsCheck')); @@ -368,4 +369,8 @@ router.post('/validateResetPassword', async (req, res) => { } }); +router.post('/findUserAccount', findUserAccount); + +router.post('/findUsername', findUsername); + module.exports = router; diff --git a/backend/server/routes/registration/findUserAccount.js b/backend/server/routes/registration/findUserAccount.js new file mode 100644 index 0000000000..3c4b37475c --- /dev/null +++ b/backend/server/routes/registration/findUserAccount.js @@ -0,0 +1,97 @@ +const { isEmpty } = require('lodash'); +const { sanitisePostcode } = require('../../utils/postcodeSanitizer'); +const limitFindUserAccountUtils = require('../../utils/limitFindUserAccountUtils'); +const HttpError = require('../../utils/errors/httpError'); +const models = require('../../models/index'); +const { MaxFindUsernameAttempts } = require('../../data/constants'); + +class FindUserAccountException extends HttpError {} + +const findUserAccount = async (req, res) => { + try { + await validateRequest(req); + + const { name, workplaceIdOrPostcode, email } = req.body; + let userFound = null; + + const postcode = sanitisePostcode(workplaceIdOrPostcode); + if (postcode) { + userFound = await models.user.findByRelevantInfo({ name, postcode, email }); + } + + userFound = + userFound ?? (await models.user.findByRelevantInfo({ name, workplaceId: workplaceIdOrPostcode, email })); + + if (!userFound) { + const failedAttemptsCount = await limitFindUserAccountUtils.recordFailedAttempt(req.ip); + const remainingAttempts = MaxFindUsernameAttempts - failedAttemptsCount; + + return sendNotFoundResponse(res, remainingAttempts); + } + + if (userFound.multipleAccountsFound) { + return sendMultipleAccountsFoundResponse(res); + } + + if (userFound.accountLocked) { + throw new FindUserAccountException('User account is locked', 423); + } + + return sendSuccessResponse(res, userFound); + } catch (err) { + return sendErrorResponse(res, err); + } +}; + +const validateRequest = async (req) => { + if (requestBodyIsInvalid(req)) { + throw new FindUserAccountException('Invalid request', 400); + } + + if (await ipAddressReachedMaxAttempt(req)) { + throw new FindUserAccountException('Reached maximum retry', 429); + } +}; + +const requestBodyIsInvalid = (req) => { + if (!req.body) { + return true; + } + const { name, workplaceIdOrPostcode, email } = req.body; + + return [name, workplaceIdOrPostcode, email].some((field) => isEmpty(field)); +}; + +const ipAddressReachedMaxAttempt = async (req) => { + const attemptsSoFar = (await limitFindUserAccountUtils.getNumberOfFailedAttempts(req.ip)) ?? 0; + return attemptsSoFar >= MaxFindUsernameAttempts; +}; + +const sendSuccessResponse = (res, userFound) => { + const { uid, SecurityQuestionValue } = userFound; + return res.status(200).json({ + status: 'AccountFound', + accountUid: uid, + securityQuestion: SecurityQuestionValue, + }); +}; + +const sendNotFoundResponse = (res, remainingAttempts = 0) => { + return res.status(200).json({ status: 'AccountNotFound', remainingAttempts }); +}; + +const sendMultipleAccountsFoundResponse = (res) => { + return res.status(200).json({ status: 'MultipleAccountsFound' }); +}; + +const sendErrorResponse = (res, err) => { + console.error('registration POST findUserAccount - failed', err); + + if (err instanceof FindUserAccountException) { + return res.status(err.statusCode).json({ message: err.message }); + } + + return res.status(500).send('Internal server error'); +}; + +module.exports = { findUserAccount, FindUserAccountException }; diff --git a/backend/server/routes/registration/findUsername.js b/backend/server/routes/registration/findUsername.js new file mode 100644 index 0000000000..a14fa6aa3d --- /dev/null +++ b/backend/server/routes/registration/findUsername.js @@ -0,0 +1,73 @@ +const { isEmpty, unescape } = require('lodash'); +const models = require('../../models/index'); +const { MaxFindUsernameAttempts, UserAccountStatus } = require('../../data/constants'); + +const findUsername = async (req, res) => { + try { + if (requestIsInvalid(req)) { + return res.status(400).send('Invalid request'); + } + + const { uid, securityQuestionAnswer } = req.body; + const user = await models.user.findByUUID(uid); + const loginAccount = await user?.getLogin(); + + if (!user || !loginAccount) { + return res.status(404).send('User not found'); + } + + if (loginAccount.status === UserAccountStatus.Locked) { + return sendFailedResponse(res, 0); + } + + const answerIsCorrect = securityQuestionAnswer === user.SecurityQuestionAnswerValue; + + if (answerIsCorrect) { + return sendSuccessResponse(res, unescape(loginAccount.username)); + } + const remainingAttempts = await handleWrongAnswer(loginAccount); + return sendFailedResponse(res, remainingAttempts); + } catch (err) { + console.error('registration POST findUsername - failed', err); + return res.status(500).send('Internal server error'); + } +}; + +const requestIsInvalid = (req) => { + if (!req.body) { + return true; + } + const { securityQuestionAnswer, uid } = req.body; + + return [securityQuestionAnswer, uid].some((field) => isEmpty(field)); +}; + +const handleWrongAnswer = async (loginAccount) => { + const previousAttempts = loginAccount.invalidFindUsernameAttempts ?? 0; + const currentCount = previousAttempts + 1; + const remainingAttempts = Math.max(MaxFindUsernameAttempts - currentCount, 0); + + await models.sequelize.transaction(async (transaction) => { + if (remainingAttempts === 0) { + console.log('POST findUsername - failed: Number of wrong answer for security question reached limit.'); + console.log('Will lock the user account with RegistrationID:', loginAccount.registrationId); + await loginAccount.lockAccount(transaction); + } + await loginAccount.recordInvalidFindUsernameAttempts(transaction); + }); + + return remainingAttempts; +}; + +const sendSuccessResponse = (res, username) => { + return res.status(200).json({ + answerCorrect: true, + username, + }); +}; + +const sendFailedResponse = (res, remainingAttempts) => { + return res.status(401).json({ answerCorrect: false, remainingAttempts }); +}; + +module.exports = { findUsername }; diff --git a/backend/server/test/unit/routes/registration/findUserAccount.spec.js b/backend/server/test/unit/routes/registration/findUserAccount.spec.js new file mode 100644 index 0000000000..c00781fe68 --- /dev/null +++ b/backend/server/test/unit/routes/registration/findUserAccount.spec.js @@ -0,0 +1,227 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const expect = chai.expect; +const httpMocks = require('node-mocks-http'); + +const { findUserAccount } = require('../../../../routes/registration/findUserAccount'); +const limitFindUserAccountUtils = require('../../../../utils/limitFindUserAccountUtils'); +const models = require('../../../../models/index'); + +describe('backend/server/routes/registration/findUserAccount', () => { + const mockRequestBody = { name: 'Test User', workplaceIdOrPostcode: 'A1234567', email: 'test@example.com' }; + + const buildRequest = (body) => { + const request = { + method: 'POST', + url: '/api/registration/findUserAccount', + body, + }; + return httpMocks.createRequest(request); + }; + + let stubFindUser; + let stubGetNumberOfFailedAttempts; + let stubRecordFailedAttempt; + + beforeEach(() => { + stubFindUser = sinon.stub(models.user, 'findByRelevantInfo').callsFake(({ workplaceId, postcode }) => { + if (workplaceId === 'A1234567' || postcode === 'LS1 2RP') { + return { uid: 'mock-uid', SecurityQuestionValue: 'What is your favourite colour?' }; + } + return null; + }); + + let failedAttempts = 0; + stubGetNumberOfFailedAttempts = sinon + .stub(limitFindUserAccountUtils, 'getNumberOfFailedAttempts') + .resolves(failedAttempts); + stubRecordFailedAttempt = sinon.stub(limitFindUserAccountUtils, 'recordFailedAttempt').callsFake(() => { + failedAttempts += 1; + return Promise.resolve(failedAttempts); + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should respond with 200 and status: AccountFound if user account is found', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(200); + expect(res._getJSONData()).to.deep.equal({ + status: 'AccountFound', + accountUid: 'mock-uid', + securityQuestion: 'What is your favourite colour?', + }); + + expect(stubFindUser).to.have.been.calledWith({ + name: 'Test User', + workplaceId: 'A1234567', + email: 'test@example.com', + }); + expect(stubRecordFailedAttempt).not.to.be.called; + }); + + it('should find user with postcode if request body contains a postcode', async () => { + const req = buildRequest({ ...mockRequestBody, workplaceIdOrPostcode: 'LS1 2RP' }); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(200); + expect(res._getJSONData()).to.deep.equal({ + status: 'AccountFound', + accountUid: 'mock-uid', + securityQuestion: 'What is your favourite colour?', + }); + + expect(stubFindUser).to.have.been.calledWith({ + name: 'Test User', + postcode: 'LS1 2RP', + email: 'test@example.com', + }); + }); + + it('should try to search with both postcode and workplace ID if incoming param is not distinguishable', async () => { + const req = buildRequest({ ...mockRequestBody, workplaceIdOrPostcode: 'AB101AB' }); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + + expect(stubFindUser).to.have.been.calledWith({ + name: 'Test User', + postcode: 'AB10 1AB', + email: 'test@example.com', + }); + + expect(stubFindUser).to.have.been.calledWith({ + name: 'Test User', + workplaceId: 'AB101AB', + email: 'test@example.com', + }); + }); + + it('should respond with 200 and status: AccountNotFound if user account was not found', async () => { + const req = buildRequest({ ...mockRequestBody, workplaceIdOrPostcode: 'non-exist-workplace-id' }); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(200); + expect(res._getJSONData()).to.deep.equal({ + status: 'AccountNotFound', + remainingAttempts: 4, + }); + expect(stubRecordFailedAttempt).to.have.been.calledOnce; + }); + + it('should respond with status: AccountNotFound and a reducing number of remainingAttempts on successive failure', async () => { + for (const expectedRemainingAttempts of [4, 3, 2, 1, 0]) { + const req = buildRequest({ ...mockRequestBody, workplaceIdOrPostcode: 'non-exist-workplace-id' }); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(200); + expect(res._getJSONData()).to.deep.equal({ + status: 'AccountNotFound', + remainingAttempts: expectedRemainingAttempts, + }); + } + expect(stubRecordFailedAttempt).to.have.been.callCount(5); + }); + + it('should respond with 200 and status: MultipleAccountsFound if more than one user accounts are found', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + stubFindUser.resolves({ multipleAccountsFound: true }); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(200); + expect(res._getJSONData()).to.deep.equal({ + status: 'MultipleAccountsFound', + }); + + expect(stubRecordFailedAttempt).not.to.be.called; + }); + + describe('errors', () => { + beforeEach(() => { + sinon.stub(console, 'error'); // suppress noisy logging + }); + + it('should respond with 400 error if request does not have a body', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/registration/findUserAccount', + }); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + expect(res.statusCode).to.equal(400); + }); + + it('should respond with 400 error if request body is empty', async () => { + const req = buildRequest({}); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + expect(res.statusCode).to.equal(400); + }); + + Object.keys(mockRequestBody).forEach((field) => { + it(`should respond with 400 error if ${field} is missing from request body`, async () => { + const body = { ...mockRequestBody }; + delete body[field]; + + const req = buildRequest(body); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + expect(res.statusCode).to.equal(400); + }); + }); + + it('should respond with 423 Locked if the user account is locked', async () => { + stubFindUser.resolves({ accountLocked: true }); + + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(423); + expect(res._getJSONData()).to.deep.equal({ message: 'User account is locked' }); + }); + + it('should respond with 429 Too many request and dont run a user search if already reached maximum failure counts', async () => { + stubGetNumberOfFailedAttempts.resolves(5); + + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(429); + expect(res._getJSONData()).to.deep.equal({ message: 'Reached maximum retry' }); + expect(stubFindUser).not.to.be.called; + }); + + it('should respond with 500 Internal server error if an error occur when finding user', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + + stubFindUser.rejects(new Error('mock database error')); + + await findUserAccount(req, res); + + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Internal server error'); + }); + }); +}); diff --git a/backend/server/test/unit/routes/registration/findUsername.spec.js b/backend/server/test/unit/routes/registration/findUsername.spec.js new file mode 100644 index 0000000000..2e069cd89d --- /dev/null +++ b/backend/server/test/unit/routes/registration/findUsername.spec.js @@ -0,0 +1,197 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const expect = chai.expect; +const httpMocks = require('node-mocks-http'); + +const { findUsername } = require('../../../../routes/registration/findUsername'); +const models = require('../../../../models/index'); +const { UserAccountStatus } = require('../../../../data/constants'); + +describe('backend/server/routes/registration/findUsername', () => { + const mockRequestBody = { + uid: 'mock-uid', + securityQuestionAnswer: '42', + }; + + const buildRequest = (body) => { + const request = { + method: 'POST', + url: '/api/registration/findUsername', + body, + }; + return httpMocks.createRequest(request); + }; + + let stubFindUserByUUID; + let mockLoginModel; + let mockUserModel; + + beforeEach(() => { + mockLoginModel = { + invalidFindUsernameAttempts: 0, + status: null, + username: 'test-user', + lockAccount: sinon.stub(), + recordInvalidFindUsernameAttempts: sinon.stub(), + }; + mockLoginModel.recordInvalidFindUsernameAttempts.callsFake(() => { + mockLoginModel.invalidFindUsernameAttempts += 1; + }); + + mockUserModel = { + uid: 'mock-uid', + SecurityQuestionAnswerValue: '42', + getLogin: () => mockLoginModel, + }; + stubFindUserByUUID = sinon.stub(models.user, 'findByUUID').callsFake((uid) => { + return uid === mockUserModel.uid ? mockUserModel : null; + }); + sinon.stub(models.sequelize, 'transaction').callsFake((dbOperations) => dbOperations()); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should respond with 200 and username if securityQuestionAnswer is correct', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(200); + expect(res._getJSONData()).to.deep.equal({ + answerCorrect: true, + username: 'test-user', + }); + + expect(stubFindUserByUUID).to.have.been.calledWith('mock-uid'); + expect(mockLoginModel.recordInvalidFindUsernameAttempts).not.to.have.been.called; + expect(mockLoginModel.lockAccount).not.to.have.been.called; + }); + + it('should respond with 401 Unauthorised and number of remainingAttempts if securityQuestionAnswer is incorrect', async () => { + const req = buildRequest({ uid: 'mock-uid', securityQuestionAnswer: 'some random thing' }); + const res = httpMocks.createResponse(); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(401); + expect(res._getJSONData()).to.deep.equal({ + answerCorrect: false, + remainingAttempts: 4, + }); + + expect(mockLoginModel.recordInvalidFindUsernameAttempts).to.have.been.calledOnce; + expect(mockLoginModel.lockAccount).not.to.have.been.called; + }); + + it('should respond with reducing number of remainingAttempts on successive wrong answers', async () => { + for (const i of [1, 2, 3, 4, 5]) { + const expectedRemainingAttempts = 5 - i; + const req = buildRequest({ uid: 'mock-uid', securityQuestionAnswer: 'some random answer' }); + const res = httpMocks.createResponse(); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(401); + expect(res._getJSONData()).to.deep.equal({ + answerCorrect: false, + remainingAttempts: expectedRemainingAttempts, + }); + expect(mockLoginModel.recordInvalidFindUsernameAttempts).to.have.been.callCount(i); + + if (i === 5) { + expect(mockLoginModel.lockAccount).to.have.been.called; + } else { + expect(mockLoginModel.lockAccount).not.to.have.been.called; + } + } + }); + + it('should respond with 401 Unauthorised if account is locked, even if the answer is correct', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + mockLoginModel.status = UserAccountStatus.Locked; + + await findUsername(req, res); + + expect(res.statusCode).to.equal(401); + expect(res._getJSONData()).to.deep.equal({ + answerCorrect: false, + remainingAttempts: 0, + }); + }); + + it('should respond with 400 error if request does not have a body', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/registration/findUsername', + }); + const res = httpMocks.createResponse(); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(400); + }); + + it('should respond with 400 error if request body is empty', async () => { + const req = buildRequest({}); + const res = httpMocks.createResponse(); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(400); + }); + + it('should respond with 400 error if securityQuestionAnswer is missing', async () => { + const req = buildRequest({ uid: mockRequestBody.uid }); + const res = httpMocks.createResponse(); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(400); + }); + + it('should respond with 400 error if uid is missing', async () => { + const req = buildRequest({ securityQuestionAnswer: mockRequestBody.securityQuestionAnswer }); + const res = httpMocks.createResponse(); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(400); + }); + + it('should respond with 404 error if cannot find a user with the given uid', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + stubFindUserByUUID.resolves(null); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(404); + }); + + it('should respond with 404 error if the user does not have login info setup yet', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + mockUserModel.getLogin = () => null; + + await findUsername(req, res); + + expect(res.statusCode).to.equal(404); + }); + + it('should respond with 500 Internal server error if an error is thrown', async () => { + const req = buildRequest(mockRequestBody); + const res = httpMocks.createResponse(); + + sinon.stub(console, 'error'); // suppress noisy logging + stubFindUserByUUID.rejects(new Error('mock database error')); + + await findUsername(req, res); + + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Internal server error'); + }); +}); diff --git a/backend/server/utils/limitFindUserAccountUtils.js b/backend/server/utils/limitFindUserAccountUtils.js new file mode 100644 index 0000000000..6adb58c5dc --- /dev/null +++ b/backend/server/utils/limitFindUserAccountUtils.js @@ -0,0 +1,19 @@ +const config = require('../config/config'); +const RedisClient = require('ioredis'); + +const redisClient = new RedisClient(config.get('redis.url'), { keyPrefix: 'findUserAccountAttempts:' }); + +const getNumberOfFailedAttempts = async (ipAddress) => { + const rawValue = await redisClient.get(ipAddress); + return rawValue ? Number(rawValue) : 0; +}; + +const recordFailedAttempt = async (ipAddress) => { + const ttl = 60 * 60; // 1 hour + const failedAttemptsCount = await redisClient.incr(ipAddress); + await redisClient.expire(ipAddress, ttl); + + return failedAttemptsCount; +}; + +module.exports = { getNumberOfFailedAttempts, recordFailedAttempt }; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 1be4232f4f..cd991243ca 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -28,7 +28,12 @@ import { WorkplaceResolver } from '@core/resolvers/workplace.resolver'; import { AdminComponent } from '@features/admin/admin.component'; import { AscWdsCertificateComponent } from '@features/dashboard/asc-wds-certificate/asc-wds-certificate.component'; import { FirstLoginPageComponent } from '@features/first-login-page/first-login-page.component'; -import { ForgotYourPasswordComponent } from '@features/forgot-your-password/forgot-your-password.component'; +import { ForgotYourPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component'; +import { ForgotYourUsernameOrPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-username-or-password.component'; +import { ForgotYourUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component'; +import { UsernameFoundComponent } from '@features/forgot-your-username-or-password/username-found/username-found.component'; +import { SecurityQuestionAnswerNotMatchComponent } from '@features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component'; +import { UserAccountNotFoundComponent } from '@features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component'; import { LoginComponent } from '@features/login/login.component'; import { LogoutComponent } from '@features/logout/logout.component'; import { MigratedUserTermsConditionsComponent } from '@features/migrated-user-terms-conditions/migrated-user-terms-conditions.component'; @@ -89,6 +94,27 @@ const routes: Routes = [ component: ForgotYourPasswordComponent, data: { title: 'Forgotten Password' }, }, + { + path: 'forgot-your-username-or-password', + component: ForgotYourUsernameOrPasswordComponent, + data: { title: 'Forgot Your Username Or Password' }, + }, + { + path: 'forgot-your-username', + component: ForgotYourUsernameComponent, + data: { title: 'Forgot Your Username' }, + }, + { + path: 'user-account-not-found', + component: UserAccountNotFoundComponent, + data: { title: 'User Account Not found' }, + }, + + { + path: 'security-question-answer-not-match', + component: SecurityQuestionAnswerNotMatchComponent, + data: { title: 'Security Question Answer Does Not Match' }, + }, { path: 'reset-password', component: ResetPasswordComponent, @@ -99,6 +125,11 @@ const routes: Routes = [ component: SatisfactionSurveyComponent, data: { title: 'Satisfaction Survey' }, }, + { + path: 'username-found', + component: UsernameFoundComponent, + data: { title: 'Username Found' }, + }, ], }, { diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 10396b25f2..df642bda1b 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -1,12 +1,13 @@ +import { Angulartics2Module } from 'angulartics2'; +import { HighchartsChartModule } from 'highcharts-angular'; + import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient, HttpClientModule } from '@angular/common/http'; import { ErrorHandler, NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule } from '@angular/router'; -import { - ProblemWithTheServiceComponent, -} from '@core/components/error/problem-with-the-service/problem-with-the-service.component'; +import { ProblemWithTheServiceComponent } from '@core/components/error/problem-with-the-service/problem-with-the-service.component'; import { ServiceUnavailableComponent } from '@core/components/error/service-unavailable/service-unavailable.component'; import { FooterComponent } from '@core/components/footer/footer.component'; import { HeaderComponent } from '@core/components/header/header.component'; @@ -20,9 +21,7 @@ import { AllUsersForEstablishmentResolver } from '@core/resolvers/dashboard/all- import { TotalStaffRecordsResolver } from '@core/resolvers/dashboard/total-staff-records.resolver'; import { FundingReportResolver } from '@core/resolvers/funding-report.resolver'; import { GetMissingCqcLocationsResolver } from '@core/resolvers/getMissingCqcLocations/getMissingCqcLocations.resolver'; -import { - GetNoOfWorkersWhoRequireInternationalRecruitmentAnswersResolver, -} from '@core/resolvers/international-recruitment/no-of-workers-who-require-international-recruitment-answers.resolver'; +import { GetNoOfWorkersWhoRequireInternationalRecruitmentAnswersResolver } from '@core/resolvers/international-recruitment/no-of-workers-who-require-international-recruitment-answers.resolver'; import { LoggedInUserResolver } from '@core/resolvers/logged-in-user.resolver'; import { NotificationsListResolver } from '@core/resolvers/notifications-list.resolver'; import { PageResolver } from '@core/resolvers/page.resolver'; @@ -56,21 +55,23 @@ import { windowProvider, WindowToken } from '@core/services/window'; import { WindowRef } from '@core/services/window.ref'; import { WorkerService } from '@core/services/worker.service'; import { AdminSkipService } from '@features/bulk-upload/admin-skip.service'; -import { - ParentWorkplaceAccounts, -} from '@features/create-account/workplace/parent-workplace-accounts/parent-workplace-accounts.component'; -import { - SelectMainServiceComponent, -} from '@features/create-account/workplace/select-main-service/select-main-service.component'; +import { ParentWorkplaceAccounts } from '@features/create-account/workplace/parent-workplace-accounts/parent-workplace-accounts.component'; +import { SelectMainServiceComponent } from '@features/create-account/workplace/select-main-service/select-main-service.component'; import { AscWdsCertificateComponent } from '@features/dashboard/asc-wds-certificate/asc-wds-certificate.component'; import { DashboardHeaderComponent } from '@features/dashboard/dashboard-header/dashboard-header.component'; import { DashboardComponent } from '@features/dashboard/dashboard.component'; import { HomeTabComponent } from '@features/dashboard/home-tab/home-tab.component'; import { FirstLoginPageComponent } from '@features/first-login-page/first-login-page.component'; import { FirstLoginWizardComponent } from '@features/first-login-wizard/first-login-wizard.component'; -import { ForgotYourPasswordConfirmationComponent } from '@features/forgot-your-password/confirmation/confirmation.component'; -import { ForgotYourPasswordEditComponent } from '@features/forgot-your-password/edit/edit.component'; -import { ForgotYourPasswordComponent } from '@features/forgot-your-password/forgot-your-password.component'; +import { ForgotYourPasswordConfirmationComponent } from '@features/forgot-your-username-or-password/forgot-your-password/confirmation/confirmation.component'; +import { ForgotYourPasswordEditComponent } from '@features/forgot-your-username-or-password/forgot-your-password/edit/edit.component'; +import { ForgotYourPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component'; +import { ForgotYourUsernameOrPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-username-or-password.component'; +import { FindAccountComponent } from '@features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component'; +import { FindUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component'; +import { ForgotYourUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component'; +import { SecurityQuestionAnswerNotMatchComponent } from '@features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component'; +import { UserAccountNotFoundComponent } from '@features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component'; import { LoginComponent } from '@features/login/login.component'; import { LogoutComponent } from '@features/logout/logout.component'; import { BecomeAParentComponent } from '@features/new-dashboard/become-a-parent/become-a-parent.component'; @@ -88,26 +89,19 @@ import { NewWorkplaceTabComponent } from '@features/new-dashboard/workplace-tab/ import { ResetPasswordConfirmationComponent } from '@features/reset-password/confirmation/confirmation.component'; import { ResetPasswordEditComponent } from '@features/reset-password/edit/edit.component'; import { ResetPasswordComponent } from '@features/reset-password/reset-password.component'; -import { - SelectStarterJobRolesComponent, -} from '@features/workplace/select-starter-job-roles/select-starter-job-roles.component'; +import { SelectStarterJobRolesComponent } from '@features/workplace/select-starter-job-roles/select-starter-job-roles.component'; import { BenchmarksModule } from '@shared/components/benchmarks-tab/benchmarks.module'; import { DataAreaTabModule } from '@shared/components/data-area-tab/data-area-tab.module'; import { FeatureFlagsService } from '@shared/services/feature-flags.service'; import { SharedModule } from '@shared/shared.module'; -import { Angulartics2Module } from 'angulartics2'; -import { HighchartsChartModule } from 'highcharts-angular'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; -import { - StaffMismatchBannerComponent, -} from './features/dashboard/home-tab/staff-mismatch-banner/staff-mismatch-banner.component'; -import { - MigratedUserTermsConditionsComponent, -} from './features/migrated-user-terms-conditions/migrated-user-terms-conditions.component'; +import { StaffMismatchBannerComponent } from './features/dashboard/home-tab/staff-mismatch-banner/staff-mismatch-banner.component'; +import { MigratedUserTermsConditionsComponent } from './features/migrated-user-terms-conditions/migrated-user-terms-conditions.component'; import { SatisfactionSurveyComponent } from './features/satisfaction-survey/satisfaction-survey.component'; import { SentryErrorHandler } from './SentryErrorHandler.component'; +import { UsernameFoundComponent } from '@features/forgot-your-username-or-password/username-found/username-found.component'; @NgModule({ declarations: [ @@ -149,7 +143,14 @@ import { SentryErrorHandler } from './SentryErrorHandler.component'; LinkToParentComponent, ParentWorkplaceAccounts, DeleteWorkplaceComponent, + ForgotYourUsernameOrPasswordComponent, + UsernameFoundComponent, + ForgotYourUsernameComponent, + FindAccountComponent, + FindUsernameComponent, SelectStarterJobRolesComponent, + SecurityQuestionAnswerNotMatchComponent, + UserAccountNotFoundComponent, ], imports: [ Angulartics2Module.forRoot({ diff --git a/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.html b/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.html new file mode 100644 index 0000000000..258f2ae594 --- /dev/null +++ b/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.html @@ -0,0 +1,7 @@ +
+
+

Page no longer available

+

The page you are trying to view does not exist anymore.

+

Return to the homepage

+
+
diff --git a/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.spec.ts b/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.spec.ts new file mode 100644 index 0000000000..a43de9798d --- /dev/null +++ b/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.spec.ts @@ -0,0 +1,41 @@ +import { render } from '@testing-library/angular'; +import { RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { PageNoLongerAvailableComponent } from './page-no-longer-available.component'; + +describe('PageNoLongerAvailableComponent', () => { + const setup = async () => { + const setupTools = await render(PageNoLongerAvailableComponent, { + imports: [RouterModule, RouterTestingModule, HttpClientTestingModule], + declarations: [], + providers: [], + componentProperties: {}, + }); + + const component = setupTools.fixture.componentInstance; + + return { ...setupTools, component }; + }; + + it('should create', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + it('should show the page heading', async () => { + const { getByRole } = await setup(); + + expect(getByRole('heading', { name: 'Page no longer available' })).toBeTruthy(); + }); + + it('should show a link to the homepage', async () => { + const { getByText } = await setup(); + + const linkText = getByText('Return to the homepage'); + + expect(linkText).toBeTruthy(); + expect(linkText.getAttribute('href')).toEqual('/login'); + }); +}); diff --git a/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.ts b/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.ts new file mode 100644 index 0000000000..ad7a1e020c --- /dev/null +++ b/frontend/src/app/core/components/error/page-no-longer-available/page-no-longer-available.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-page-no-longer-available', + templateUrl: './page-no-longer-available.component.html', +}) +export class PageNoLongerAvailableComponent { + constructor() {} +} diff --git a/frontend/src/app/core/services/find-username.service.spec.ts b/frontend/src/app/core/services/find-username.service.spec.ts new file mode 100644 index 0000000000..748c725b95 --- /dev/null +++ b/frontend/src/app/core/services/find-username.service.spec.ts @@ -0,0 +1,84 @@ +import { environment } from 'src/environments/environment'; + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { FindUsernameService } from './find-username.service'; + +describe('FindUsernameService', () => { + let service: FindUsernameService; + let http: HttpTestingController; + const mockSubscriber = jasmine.createSpy(); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + service = TestBed.inject(FindUsernameService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + mockSubscriber.calls.reset(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('findUserAccount', () => { + const mockParams = { name: 'Test user', workplaceIdOrPostcode: 'A1234567', email: 'test@example.com' }; + + it('should make a POST request to /registration/findUserAccount endpoint with the given search params', async () => { + service.findUserAccount(mockParams).subscribe(); + const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUserAccount`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(mockParams); + }); + + it('should handle a 429 "Too many request" response and convert it to AccountNotFound with remainingAttempts: 0', async () => { + service.findUserAccount(mockParams).subscribe(mockSubscriber); + const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUserAccount`); + req.flush({ message: 'Reached maximum retry' }, { status: 429, statusText: 'Too many request' }); + + const expectedResult = { status: 'AccountNotFound', remainingAttempts: 0 }; + + expect(mockSubscriber).toHaveBeenCalledOnceWith(expectedResult); + }); + + it('should handle a 423 "Locked" response and convert it to AccountLocked', async () => { + service.findUserAccount(mockParams).subscribe(mockSubscriber); + const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUserAccount`); + req.flush({ message: 'User account is locked' }, { status: 423, statusText: 'Locked' }); + + const expectedResult = { status: 'AccountLocked' }; + + expect(mockSubscriber).toHaveBeenCalledOnceWith(expectedResult); + }); + }); + + describe('findUsername', () => { + it('should make a POST request to /registration/findUsername endpoint with uid and security question answer', async () => { + const mockParams = { uid: 'mock-uid', securityQuestionAnswer: '42' }; + + service.findUsername(mockParams).subscribe(); + const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUsername`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(mockParams); + }); + + it('should handle a 401 Unauthorised response and convert it to AnswerIncorrect', async () => { + const mockParams = { uid: 'mock-uid', securityQuestionAnswer: '42' }; + + service.findUsername(mockParams).subscribe(mockSubscriber); + const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUsername`); + req.flush({ answerCorrect: false, remainingAttempts: 4 }, { status: 401, statusText: 'Unauthorised' }); + + const expectedResult = { answerCorrect: false, remainingAttempts: 4 }; + + expect(mockSubscriber).toHaveBeenCalledOnceWith(expectedResult); + }); + }); +}); diff --git a/frontend/src/app/core/services/find-username.service.ts b/frontend/src/app/core/services/find-username.service.ts new file mode 100644 index 0000000000..102683ea9b --- /dev/null +++ b/frontend/src/app/core/services/find-username.service.ts @@ -0,0 +1,99 @@ +import { Observable, of, throwError } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { catchError, map } from 'rxjs/operators'; + +export interface FindAccountRequest { + name: string; + workplaceIdOrPostcode: string; + email: string; +} + +export interface AccountFound { + status: 'AccountFound'; + accountUid: string; + securityQuestion: string; +} +interface AccountNotFound { + status: 'AccountNotFound'; + remainingAttempts: number; +} + +interface MultipleAccountsFound { + status: 'MultipleAccountsFound'; +} + +export interface AccountLocked { + status: 'AccountLocked'; +} + +export type FindUserAccountResponse = AccountFound | AccountNotFound | MultipleAccountsFound | AccountLocked; + +export interface FindUsernameRequest { + uid: string; + securityQuestionAnswer: string; +} + +interface AnswerCorrect { + answerCorrect: true; + username: string; +} +interface AnswerIncorrect { + answerCorrect: false; + remainingAttempts: number; +} + +export type FindUsernameResponse = AnswerCorrect | AnswerIncorrect; + +@Injectable({ + providedIn: 'root', +}) +export class FindUsernameService { + public usernameFound: string = null; + + constructor(private http: HttpClient) {} + + findUserAccount(params: FindAccountRequest): Observable { + return this.http + .post(`${environment.appRunnerEndpoint}/api/registration/findUserAccount`, params) + .pipe(catchError((res) => this.handleFindUserAccountErrors(res))); + } + + handleFindUserAccountErrors(err: HttpErrorResponse): Observable { + switch (err?.status) { + case 429: // Too many request + const remainingAttempts = err.error?.remainingAttempts ?? 0; + return of({ + status: 'AccountNotFound', + remainingAttempts, + }); + case 423: // Locked + return of({ + status: 'AccountLocked', + }); + } + + throwError(err); + } + + findUsername(params: FindUsernameRequest): Observable { + return this.http + .post(`${environment.appRunnerEndpoint}/api/registration/findUsername`, params) + .pipe(catchError((res) => this.handleFindUsernameErrors(res))); + } + + handleFindUsernameErrors(err: HttpErrorResponse): Observable { + if (err.status === 401) { + const remainingAttempts = err.error?.remainingAttempts ?? 0; + + return of({ + answerCorrect: false, + remainingAttempts, + }); + } + + throwError(err); + } +} diff --git a/frontend/src/app/core/test-utils/MockFindUsernameService.ts b/frontend/src/app/core/test-utils/MockFindUsernameService.ts new file mode 100644 index 0000000000..38fb091c8b --- /dev/null +++ b/frontend/src/app/core/test-utils/MockFindUsernameService.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { FindAccountRequest, FindUsernameRequest, FindUsernameService } from '@core/services/find-username.service'; +import { Observable, of } from 'rxjs'; + +export const mockTestUser = { + accountUid: 'mock-user-uid', + securityQuestion: 'What is your favourite colour?', + securityQuestionAnswer: 'Blue', + username: 'mock-test-user', +}; + +@Injectable() +export class MockFindUsernameService extends FindUsernameService { + findUserAccount(params: FindAccountRequest): ReturnType { + if (params.name === 'non-exist user') { + return of({ + status: 'AccountNotFound', + remainingAttempts: 4, + }); + } + + return of({ + status: 'AccountFound', + accountUid: 'mock-user-uid', + securityQuestion: 'What is your favourite colour?', + }); + } + + findUsername(params: FindUsernameRequest): ReturnType { + if (params.securityQuestionAnswer === mockTestUser.securityQuestionAnswer) { + return of({ + answerCorrect: true, + username: mockTestUser.username, + }); + } + + return of({ + answerCorrect: false, + remainingAttempts: 4, + }); + } +} diff --git a/frontend/src/app/features/forgot-your-password/confirmation/confirmation.component.html b/frontend/src/app/features/forgot-your-password/confirmation/confirmation.component.html deleted file mode 100644 index ef2cbf6e91..0000000000 --- a/frontend/src/app/features/forgot-your-password/confirmation/confirmation.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
-

Reset link sent

-

If there's an ASC-WDS account for {{ emailAddress }}, you'll receive an email with a reset password link.

-

- The email should be in your inbox soon, if not, check your spam folder before you - contact us for help. -

- - - Return to sign in - -
-
diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/confirmation/confirmation.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/confirmation/confirmation.component.html new file mode 100644 index 0000000000..7fc8ae5f89 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/confirmation/confirmation.component.html @@ -0,0 +1,17 @@ +
+
+

Password reset link sent

+

+ If there's an ASC-WDS account for {{ emailAddress }} you'll get an email soon, with a link to + reset your password. +

+

+ The email should go to your inbox, but if not then check your spam folder before you + contact us for help. +

+ + + Back to sign in + +
+
diff --git a/frontend/src/app/features/forgot-your-password/confirmation/confirmation.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/confirmation/confirmation.component.ts similarity index 100% rename from frontend/src/app/features/forgot-your-password/confirmation/confirmation.component.ts rename to frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/confirmation/confirmation.component.ts diff --git a/frontend/src/app/features/forgot-your-password/edit/edit.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/edit/edit.component.html similarity index 58% rename from frontend/src/app/features/forgot-your-password/edit/edit.component.html rename to frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/edit/edit.component.html index d849169c39..21b2acbbe0 100644 --- a/frontend/src/app/features/forgot-your-password/edit/edit.component.html +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/edit/edit.component.html @@ -10,11 +10,14 @@
- -

Forgot your password?

+ +

Forgot password

-

Enter your username or email address and we will send you a link to reset your password.

+

+ Enter your username or your ASC-WDS email address (this'll be the one you used when you created your account, + unless you've changed it) and we'll send you a link to reset your password. +

Forgot your password? - Error: {{ getFormErrorMessage('usernameOrEmail', 'required') }} + Error: {{ getFirstErrorMessage('usernameOrEmail') }} Forgot your password?
- +
+ + or + Back to sign in +
-

Back to sign in

diff --git a/frontend/src/app/features/forgot-your-password/edit/edit.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/edit/edit.component.ts similarity index 83% rename from frontend/src/app/features/forgot-your-password/edit/edit.component.ts rename to frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/edit/edit.component.ts index 8dd33b643d..fcda4618be 100644 --- a/frontend/src/app/features/forgot-your-password/edit/edit.component.ts +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/edit/edit.component.ts @@ -37,20 +37,19 @@ export class ForgotYourPasswordEditComponent implements OnInit, AfterViewInit { type: [ { name: 'required', - message: 'Enter the username or email address', + message: 'Enter your username or ASC-WDS email address', + }, + { + name: 'maxlength', + message: 'Your username or ASC-WDS email address must be 120 characters or fewer', }, ], }, ]; } - /** - * Pass in formGroup or formControl name and errorType - * Then return error message - * @param item - * @param errorType - */ - public getFormErrorMessage(item: string, errorType: string): string { + public getFirstErrorMessage(item: string): string { + const errorType = Object.keys(this.form.get(item).errors)[0]; return this.errorSummaryService.getFormErrorMessage(item, errorType, this.formErrorsMap); } diff --git a/frontend/src/app/features/forgot-your-password/forgot-your-password.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component.html similarity index 100% rename from frontend/src/app/features/forgot-your-password/forgot-your-password.component.html rename to frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component.html diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component.spec.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component.spec.ts new file mode 100644 index 0000000000..65b506db4c --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component.spec.ts @@ -0,0 +1,118 @@ +import { of } from 'rxjs'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { getTestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { PasswordResetService } from '@core/services/password-reset.service'; +import { SharedModule } from '@shared/shared.module'; +import { render } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +import { ForgotYourPasswordConfirmationComponent } from './confirmation/confirmation.component'; +import { ForgotYourPasswordEditComponent } from './edit/edit.component'; +import { ForgotYourPasswordComponent } from './forgot-your-password.component'; + +describe('ForgotYourPasswordComponent', () => { + const setup = async () => { + const setupTools = await render(ForgotYourPasswordComponent, { + imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule, RouterTestingModule, SharedModule], + declarations: [ForgotYourPasswordEditComponent, ForgotYourPasswordConfirmationComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: {}, + }, + }, + ], + }); + + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + const router = injector.inject(Router) as Router; + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + const passwordResetService = injector.inject(PasswordResetService); + const passwordResetSpy = spyOn(passwordResetService, 'requestPasswordReset').and.returnValue(of(null)); + + return { ...setupTools, component, routerSpy, passwordResetSpy }; + }; + + it('should create', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + describe('rendering', () => { + it('should show a page heading', async () => { + const { getByRole } = await setup(); + + expect(getByRole('heading', { name: 'Forgot password' })).toBeTruthy(); + }); + + it('should show a textbox to input username or email address', async () => { + const { getByRole } = await setup(); + + expect(getByRole('textbox', { name: 'Username or email address' })).toBeTruthy(); + }); + + it('should show a "Send password reset link" CTA button and a "Back to sign in" link', async () => { + const { getByText, getByRole } = await setup(); + + expect(getByRole('button', { name: 'Send password reset link' })).toBeTruthy(); + + const backToSignIn = getByText('Back to sign in'); + expect(backToSignIn).toBeTruthy(); + expect(backToSignIn.getAttribute('href')).toEqual('/login'); + }); + }); + + describe('form submit and validation', () => { + describe('on submit', () => { + it('should make a request from password reset service', async () => { + const { getByRole, passwordResetSpy } = await setup(); + + userEvent.type(getByRole('textbox'), 'test@example.com'); + userEvent.click(getByRole('button', { name: 'Send password reset link' })); + + expect(passwordResetSpy).toHaveBeenCalledWith('test@example.com'); + }); + + describe('error', () => { + it('should show an error message if no input for textbox', async () => { + const { fixture, getByText, getByRole, getAllByText, passwordResetSpy } = await setup(); + + userEvent.click(getByRole('button', { name: 'Send password reset link' })); + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + expect(getAllByText('Enter your username or ASC-WDS email address')).toHaveSize(2); + + expect(passwordResetSpy).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('after form submit', async () => { + it('should show a confirmation screen', async () => { + const { fixture, getByRole, getByText, getByTestId } = await setup(); + + userEvent.type(getByRole('textbox'), 'test@example.com'); + userEvent.click(getByRole('button', { name: 'Send password reset link' })); + + fixture.detectChanges(); + + const expectedMessage = + "If there's an ASC-WDS account for test@example.com you'll get an email soon, with a link to reset your password."; + + expect(getByRole('heading', { name: 'Password reset link sent' })).toBeTruthy(); + expect(getByTestId('confirmation-message').textContent).toContain(expectedMessage); + expect(getByText('Back to sign in')).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/app/features/forgot-your-password/forgot-your-password.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component.ts similarity index 100% rename from frontend/src/app/features/forgot-your-password/forgot-your-password.component.ts rename to frontend/src/app/features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component.ts diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.html new file mode 100644 index 0000000000..ad8b8894b6 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.html @@ -0,0 +1,70 @@ + + + +
+
+
+
+
+ +

Forgot your username or password?

+
+

+ Error: {{ getFirstErrorMessage('usernameOrPassword') }} +

+
+
+ + +
+
+ + +
+
+
+
+ +
+ +

+ Request a link to reset your password + and then come back here to find your username. Alternatively, call the ASC-WDS Support Team on + 0113 241 0969 for help. +

+
+
+ +
+ + or + Back to sign in +
+
+
+
diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.spec.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.spec.ts new file mode 100644 index 0000000000..3edc33bf13 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.spec.ts @@ -0,0 +1,116 @@ +import { getTestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SharedModule } from '@shared/shared.module'; +import { render, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +import { ForgotYourUsernameOrPasswordComponent } from './forgot-your-username-or-password.component'; + +describe('ForgotYourUsernameOrPasswordComponent', () => { + const setup = async () => { + const setupTools = await render(ForgotYourUsernameOrPasswordComponent, { + imports: [FormsModule, ReactiveFormsModule, SharedModule, RouterTestingModule], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: {}, + }, + }, + ], + }); + + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + const router = injector.inject(Router) as Router; + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + return { ...setupTools, component, routerSpy }; + }; + + it('should create', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + describe('rendering', () => { + it('should show a page heading', async () => { + const { getByRole } = await setup(); + + expect(getByRole('heading', { name: 'Forgot your username or password?' })).toBeTruthy(); + }); + + it('should show radio buttons to choose from Username or Password', async () => { + const { getByRole } = await setup(); + + expect(getByRole('radio', { name: 'Username' })).toBeTruthy(); + expect(getByRole('radio', { name: 'Password' })).toBeTruthy(); + }); + + it('it should show an reveal text of "Forgot both?"', async () => { + const { getByTestId, getByText } = await setup(); + + const revealTextElement = getByTestId('reveal-text'); + const hiddenText = + 'Request a link to reset your password and then come back here to find your username. ' + + 'Alternatively, call the ASC-WDS Support Team on 0113 241 0969 for help.'; + + expect(revealTextElement).toBeTruthy(); + expect(within(revealTextElement).getByText('Forgot both?')).toBeTruthy(); + expect(revealTextElement.textContent).toContain(hiddenText); + + const linkToResetPassword = getByText('Request a link to reset your password'); + expect(linkToResetPassword.getAttribute('href')).toEqual('/forgot-your-password'); + }); + + it('should show a "Continue" CTA button and a "Back to sign in" link', async () => { + const { getByText, getByRole } = await setup(); + + expect(getByRole('button', { name: 'Continue' })).toBeTruthy(); + + const backToSignIn = getByText('Back to sign in'); + expect(backToSignIn).toBeTruthy(); + expect(backToSignIn.getAttribute('href')).toEqual('/login'); + }); + }); + + describe('form submit and validation', () => { + describe('on submit', () => { + it('should nagivate to forgot-your-username page if username was selected', async () => { + const { getByRole, routerSpy } = await setup(); + + userEvent.click(getByRole('radio', { name: 'Username' })); + userEvent.click(getByRole('button', { name: 'Continue' })); + + expect(routerSpy).toHaveBeenCalledWith(['/forgot-your-username']); + }); + + it('should nagivate to forgot-your-password page if password was selected', async () => { + const { getByRole, routerSpy } = await setup(); + + userEvent.click(getByRole('radio', { name: 'Password' })); + userEvent.click(getByRole('button', { name: 'Continue' })); + + expect(routerSpy).toHaveBeenCalledWith(['/forgot-your-password']); + }); + + describe('error', () => { + it('should show an error message if neither of radio buttons were selected', async () => { + const { fixture, getByText, getByRole, getAllByText, routerSpy } = await setup(); + + userEvent.click(getByRole('button', { name: 'Continue' })); + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + expect(getAllByText('Select username or password')).toHaveSize(2); + + expect(routerSpy).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.ts new file mode 100644 index 0000000000..1897bee9c5 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username-or-password.component.ts @@ -0,0 +1,73 @@ +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ErrorDetails } from '@core/model/errorSummary.model'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; + +@Component({ + selector: 'app-forgot-your-username-or-password', + templateUrl: './forgot-your-username-or-password.component.html', +}) +export class ForgotYourUsernameOrPasswordComponent implements OnInit, AfterViewInit { + @ViewChild('formEl') formEl: ElementRef; + public form: UntypedFormGroup; + public submitted = false; + public formErrorsMap: Array; + + private nextRoute: string[]; + + constructor( + private router: Router, + private formBuilder: UntypedFormBuilder, + private errorSummaryService: ErrorSummaryService, + ) {} + + ngOnInit(): void { + this.form = this.formBuilder.group({ + usernameOrPassword: [null, { updateOn: 'submit', validators: Validators.required }], + }); + this.setupFormErrorsMap(); + } + + ngAfterViewInit(): void { + this.errorSummaryService.formEl$.next(this.formEl); + } + + public setupFormErrorsMap(): void { + this.formErrorsMap = [ + { + item: 'usernameOrPassword', + type: [ + { + name: 'required', + message: 'Select username or password', + }, + ], + }, + ]; + } + + public getFirstErrorMessage(item: string): string { + const errorType = Object.keys(this.form.get(item).errors)[0]; + return this.errorSummaryService.getFormErrorMessage(item, errorType, this.formErrorsMap); + } + + onSubmit(): void { + this.submitted = true; + + const selectedOption = this.form.get('usernameOrPassword').value; + + switch (selectedOption) { + case 'username': + this.nextRoute = ['/forgot-your-username']; + break; + case 'password': + this.nextRoute = ['/forgot-your-password']; + break; + default: + return; + } + + this.router.navigate(this.nextRoute); + } +} diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component.html new file mode 100644 index 0000000000..1deeaa18f3 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component.html @@ -0,0 +1,100 @@ +
+
+

+ Enter your name, workplace ID or postcode, and your ASC-WDS email address (this'll be the one you used when you + created your account, unless you've changed it). +

+
+ +
+ + + Error: {{ getFirstErrorMessage(field.id) }} + + +
+
+ +
+ + +

+ + + Account found + +

+
+ + +
+

+ + Error + Account not found + +

+ +

+ Some or all of the information you entered does not match the information we have for your account. +

+

+ + + + You've 1 more chance to enter the same information that we have, otherwise you'll need to call the + Support Team. + + + You've {{ remainingAttempts }} more chances to enter the same information that we have. + + + +

+
+

+ Make sure the details you entered are correct or call the ASC-WDS Support Team on + 0113 241 0969 for help. +

+
+
+ + +

+ + Error + Multple accounts found + +

+

We found more than 1 account with the information you entered.

+
+

Call the ASC-WDS Support Team on 0113 241 0969 for help.

+
+
+
+ +
+ + or + Back to sign in + +
+
+
+
diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component.ts new file mode 100644 index 0000000000..49c77c9707 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component.ts @@ -0,0 +1,157 @@ +import { lowerFirst } from 'lodash'; +import { Subscription } from 'rxjs'; + +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + OnDestroy, + OnInit, + Output, + ViewChild, +} from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { EMAIL_PATTERN } from '@core/constants/constants'; +import { ErrorDetails } from '@core/model/errorSummary.model'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { AccountFound, FindUserAccountResponse, FindUsernameService } from '@core/services/find-username.service'; + +const InputFields = [ + { id: 'name', label: 'Name', maxlength: 120 }, + { id: 'workplaceIdOrPostcode', label: 'Workplace ID or postcode', maxlength: 8 }, + { id: 'email', label: 'Email address', maxlength: 120, pattern: EMAIL_PATTERN }, +]; + +@Component({ + selector: 'app-find-account', + templateUrl: './find-account.component.html', +}) +export class FindAccountComponent implements OnInit, OnDestroy, AfterViewInit { + @ViewChild('formEl') formEl: ElementRef; + @ViewChild('searchResult') searchResult: ElementRef; + + @Output() setCurrentForm = new EventEmitter(); + @Output() accountFoundEvent = new EventEmitter(); + + public form: UntypedFormGroup; + public formErrorsMap: Array; + public formFields = InputFields; + public submitted = false; + public status: FindUserAccountResponse['status']; + public remainingAttempts: number; + public serverError: string; + + private subscriptions = new Subscription(); + + constructor( + private FormBuilder: UntypedFormBuilder, + private errorSummaryService: ErrorSummaryService, + private findUsernameService: FindUsernameService, + private router: Router, + ) {} + + ngOnInit() { + const formConfigs = {}; + this.formFields.forEach((field) => { + const validators = [Validators.required, Validators.maxLength(field.maxlength)]; + if (field.pattern) { + validators.push(Validators.pattern(field.pattern)); + } + formConfigs[field.id] = ['', { validators, updateOn: 'submit' }]; + }); + + this.form = this.FormBuilder.group(formConfigs); + this.setupFormErrorsMap(); + this.setCurrentForm.emit(this); + } + + ngAfterViewInit() { + this.errorSummaryService.formEl$.next(this.formEl); + } + + public setupFormErrorsMap(): void { + this.formErrorsMap = this.formFields.map((field) => { + const errorMap = { + item: field.id, + type: [ + { name: 'required', message: `Enter your ${lowerFirst(field.label)}` }, + { + name: 'maxlength', + message: `Your ${lowerFirst(field.label)} must be ${field.maxlength} characters or fewer`, + }, + ], + }; + if (field.id === 'email') { + errorMap.type.push({ + name: 'pattern', + message: 'Enter the email address in the correct format, like name@example.com', + }); + } + + return errorMap; + }); + } + + public getFirstErrorMessage(item: string): string { + const errorType = Object.keys(this.form.get(item).errors)[0]; + return this.errorSummaryService.getFormErrorMessage(item, errorType, this.formErrorsMap); + } + + public onSubmit() { + this.submitted = true; + this.serverError = null; + + if (!this.form.valid) { + return; + } + + this.subscriptions.add( + this.findUsernameService.findUserAccount(this.form.value).subscribe((response) => this.handleResponse(response)), + ); + } + + public handleResponse(response: FindUserAccountResponse): void { + switch (response?.status) { + case 'AccountFound': + this.status = 'AccountFound'; + this.accountFoundEvent.emit(response); + break; + + case 'AccountNotFound': + this.status = 'AccountNotFound'; + this.remainingAttempts = response.remainingAttempts; + + if (this.remainingAttempts === 0) { + this.router.navigate(['/user-account-not-found']); + } + + this.scrollToResult(); + break; + + case 'MultipleAccountsFound': + this.status = 'MultipleAccountsFound'; + this.remainingAttempts = null; + + this.scrollToResult(); + break; + + case 'AccountLocked': + this.serverError = 'There is a problem with your account, please contact the Support Team on 0113 241 0969'; + this.status = 'AccountLocked'; + this.remainingAttempts = null; + break; + } + } + + private scrollToResult() { + setTimeout(() => { + this.searchResult.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 0); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.html new file mode 100644 index 0000000000..6a217ccf0c --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.html @@ -0,0 +1,71 @@ +
+
+
+
+
+

Your security question

+

You entered this question when you created your account.

+

Question

+
+ {{ securityQuestion }} +
+
+ +
+ + + + Error: {{ requiredErrorMessage }} + + +
+ + + + +

Call the ASC-WDS Support Team on 0113 241 0969 for help.

+
+ +
+ + or + Back to sign in +
+
+
+
diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.scss b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.scss new file mode 100644 index 0000000000..06e26830a6 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.scss @@ -0,0 +1,43 @@ +@import 'govuk-frontend/govuk/base'; + +.asc-security-question__container { + > * { + margin-top: 0; + margin-bottom: 5px; + } + + margin-bottom: govuk-spacing(6); +} + +.asc-security-question { + min-height: 2.5rem; + max-width: 20.5em; + padding: 5px 5px 5px 10px; + font-size: 19px; + box-sizing: border-box; + outline: none; + display: flex; + align-items: center; + + background-color: govuk-colour('light-grey'); +} + +.asc-incorrect-answer-message { + display: grid; + grid-template-columns: min-content 1fr; + row-gap: 5px; + margin-top: 30px; + margin-bottom: 30px; + + > img { + grid-column: 1; + } + + > p { + line-height: 22px; + grid-column: 2; + font-weight: bold; + margin-bottom: 0; + margin-right: -10px; + } +} diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.ts new file mode 100644 index 0000000000..2defe1b94f --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component.ts @@ -0,0 +1,114 @@ +import { Subscription } from 'rxjs'; + +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ErrorDetails } from '@core/model/errorSummary.model'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { FindUsernameResponse, FindUsernameService } from '@core/services/find-username.service'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-find-username', + templateUrl: './find-username.component.html', + styleUrls: ['./find-username.component.scss'], +}) +export class FindUsernameComponent implements OnInit, OnDestroy { + @Input() accountUid: string; + @Input() securityQuestion: string; + + @Output() setCurrentForm = new EventEmitter(); + + @ViewChild('formEl') formEl: ElementRef; + @ViewChild('securityQuestionEl') securityQuestionEl: ElementRef; + + public form: UntypedFormGroup; + public submitted = false; + public formErrorsMap: Array; + public requiredErrorMessage = 'Enter the answer to your security question'; + public remainingAttempts: number; + public serverError: string; + + private subscriptions = new Subscription(); + + constructor( + private FormBuilder: UntypedFormBuilder, + private errorSummaryService: ErrorSummaryService, + private findUsernameService: FindUsernameService, + private router: Router, + ) {} + + ngOnInit(): void { + this.form = this.FormBuilder.group({ + securityQuestionAnswer: ['', { validators: [Validators.required], updateOn: 'submit' }], + }); + this.setupFormErrorsMap(); + this.setCurrentForm.emit(this); + this.focusOnQuestion(); + } + + public focusOnQuestion() { + this.securityQuestionEl.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setTimeout(() => { + this.securityQuestionEl.nativeElement.focus(); + }, 500); + } + + public setupFormErrorsMap(): void { + this.formErrorsMap = [ + { + item: 'securityQuestionAnswer', + type: [ + { + name: 'required', + message: this.requiredErrorMessage, + }, + ], + }, + ]; + } + + ngAfterViewInit() { + this.errorSummaryService.formEl$.next(this.formEl); + } + + public onSubmit(): void { + this.submitted = true; + + if (!this.form.valid) { + return; + } + + const params = { + uid: this.accountUid, + securityQuestionAnswer: this.form.get('securityQuestionAnswer').value, + }; + + this.subscriptions.add( + this.findUsernameService.findUsername(params).subscribe((res) => { + this.handleResponse(res); + }), + ); + } + + public handleResponse(response: FindUsernameResponse) { + switch (response.answerCorrect) { + case true: { + this.findUsernameService.usernameFound = response.username; + this.router.navigate(['/username-found']); + break; + } + case false: { + this.remainingAttempts = response.remainingAttempts; + + if (this.remainingAttempts === 0) { + this.router.navigate(['/security-question-answer-not-match']); + } + break; + } + } + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.html new file mode 100644 index 0000000000..58546cc53f --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.html @@ -0,0 +1,15 @@ + + + + +

Forgot username

+ + + + \ No newline at end of file diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.spec.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.spec.ts new file mode 100644 index 0000000000..d836ba233a --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.spec.ts @@ -0,0 +1,381 @@ +import { of } from 'rxjs'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { getTestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { MockFindUsernameService, mockTestUser } from '@core/test-utils/MockFindUsernameService'; +import { SharedModule } from '@shared/shared.module'; +import { render, screen, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +import { FindUsernameService } from '../../../core/services/find-username.service'; +import { FindAccountComponent } from './find-account/find-account.component'; +import { FindUsernameComponent } from './find-username/find-username.component'; +import { ForgotYourUsernameComponent } from './forgot-your-username.component'; + +describe('ForgotYourUsernameComponent', () => { + const setup = async () => { + const setupTools = await render(ForgotYourUsernameComponent, { + imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule, RouterTestingModule, SharedModule], + declarations: [FindAccountComponent, FindUsernameComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: {}, + }, + }, + { + provide: FindUsernameService, + useClass: MockFindUsernameService, + }, + ], + }); + + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + const router = injector.inject(Router) as Router; + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + const findUsernameService = injector.inject(FindUsernameService) as FindUsernameService; + + return { ...setupTools, component, routerSpy, findUsernameService }; + }; + + it('should create', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + it('should show a page heading', async () => { + const { getByRole } = await setup(); + + expect(getByRole('heading', { name: 'Forgot username' })).toBeTruthy(); + }); + + describe('Find account', () => { + const fillInAndSubmitForm = async (name: string, workplaceIdOrPostcode: string, email: string) => { + userEvent.type(screen.getByRole('textbox', { name: 'Name' }), name); + userEvent.type(screen.getByRole('textbox', { name: 'Workplace ID or postcode' }), workplaceIdOrPostcode); + userEvent.type(screen.getByRole('textbox', { name: 'Email address' }), email); + + userEvent.click(screen.getByRole('button', { name: 'Find account' })); + }; + + describe('rendering', () => { + it('should show text inputs for "Name", "Workplace ID or postcode" and "Email address"', async () => { + const { getByRole } = await setup(); + + expect(getByRole('textbox', { name: 'Name' })).toBeTruthy(); + expect(getByRole('textbox', { name: 'Workplace ID or postcode' })).toBeTruthy(); + expect(getByRole('textbox', { name: 'Email address' })).toBeTruthy(); + }); + + it('should show a "Find account" CTA button and a "Back to sign in" link', async () => { + const { getByRole, getByText } = await setup(); + + expect(getByRole('button', { name: 'Find account' })).toBeTruthy(); + + const backToSignIn = getByText('Back to sign in'); + expect(backToSignIn).toBeTruthy(); + expect(backToSignIn.getAttribute('href')).toEqual('/login'); + }); + }); + + describe('submit form and validation', () => { + it('should call forgetUsernameService findUserAccount() on submit', async () => { + const { fixture, findUsernameService } = await setup(); + + spyOn(findUsernameService, 'findUserAccount').and.callThrough(); + + await fillInAndSubmitForm('Test User', 'A1234567', 'test@example.com'); + fixture.detectChanges(); + + expect(findUsernameService.findUserAccount).toHaveBeenCalledWith({ + name: 'Test User', + workplaceIdOrPostcode: 'A1234567', + email: 'test@example.com', + }); + }); + + it('should show "Account found" and stop showing "Find account" button when an account is found', async () => { + const { fixture, getByText, queryByRole } = await setup(); + + await fillInAndSubmitForm('Test User', 'A1234567', 'test@example.com'); + fixture.detectChanges(); + + expect(getByText('Account found')).toBeTruthy(); + expect(queryByRole('button', { name: 'Find account' })).toBeFalsy(); + }); + + it('should show a "Account not found" message and keep showing "Find account" button when an account is not found', async () => { + const { fixture, getByRole, getByTestId } = await setup(); + fixture.autoDetectChanges(); + + await fillInAndSubmitForm('non-exist user', 'A1234567', 'test@example.com'); + + const expectedText = [ + 'Some or all of the information you entered does not match the information we have for your account.', + "You've 4 more chances to enter the same information that we have.", + 'Make sure the details you entered are correct or call the ASC-WDS Support Team on 0113 241 0969 for help.', + ]; + + const messageDiv = getByTestId('account-not-found'); + expect(within(messageDiv).getByText('Account not found')).toBeTruthy(); + expectedText.forEach((text) => expect(messageDiv.innerText).toContain(text)); + + expect(getByRole('button', { name: 'Find account' })).toBeTruthy(); + }); + + it('should show a different error message when only 1 chance remain', async () => { + const { fixture, getByText, findUsernameService } = await setup(); + spyOn(findUsernameService, 'findUserAccount').and.returnValue( + of({ status: 'AccountNotFound', remainingAttempts: 1 }), + ); + + await fillInAndSubmitForm('non-exist user', 'A1234567', 'test@example.com'); + + fixture.detectChanges(); + + const expectedText = + "You've 1 more chance to enter the same information that we have, otherwise you'll need to call the Support Team."; + + expect(getByText(expectedText)).toBeTruthy(); + }); + + it('should navigate to "user-account-not-found" page when remaining attempts = 0', async () => { + const { findUsernameService, routerSpy } = await setup(); + spyOn(findUsernameService, 'findUserAccount').and.returnValue( + of({ status: 'AccountNotFound', remainingAttempts: 0 }), + ); + + await fillInAndSubmitForm('non-exist user', 'A1234567', 'test@example.com'); + + expect(routerSpy).toHaveBeenCalledWith(['/user-account-not-found']); + }); + + it('should show a "Multiple accounts found" message when more than 1 account was found', async () => { + const { fixture, getByRole, getByText, findUsernameService } = await setup(); + fixture.autoDetectChanges(); + + spyOn(findUsernameService, 'findUserAccount').and.returnValue(of({ status: 'MultipleAccountsFound' })); + + await fillInAndSubmitForm('Test User', 'A1234567', 'test@example.com'); + + expect(getByText('Multple accounts found')).toBeTruthy(); + expect(getByText('We found more than 1 account with the information you entered.')).toBeTruthy(); + + const textMessage = getByText('Call the ASC-WDS Support Team on', { exact: false }); + expect(textMessage.textContent).toEqual('Call the ASC-WDS Support Team on 0113 241 0969 for help.'); + + expect(getByRole('button', { name: 'Find account' })).toBeTruthy(); + }); + + describe('errors', () => { + it('should show an error message if any of the text input is blank', async () => { + const { fixture, getByRole, getByText, getAllByText, findUsernameService } = await setup(); + + spyOn(findUsernameService, 'findUserAccount').and.callThrough(); + + userEvent.click(getByRole('button', { name: 'Find account' })); + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + + expect(getAllByText('Enter your name')).toHaveSize(2); + expect(getAllByText('Enter your workplace ID or postcode')).toHaveSize(2); + expect(getAllByText('Enter your email address')).toHaveSize(2); + + expect(findUsernameService.findUserAccount).not.toHaveBeenCalled(); + }); + + it('should show an error message if the email address not in a right format', async () => { + const { fixture, getByText, getAllByText, findUsernameService } = await setup(); + + spyOn(findUsernameService, 'findUserAccount').and.callThrough(); + + await fillInAndSubmitForm('Test User', 'A1234567', 'not-a-email-address'); + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + + expect(getAllByText('Enter the email address in the correct format, like name@example.com')).toHaveSize(2); + + expect(findUsernameService.findUserAccount).not.toHaveBeenCalled(); + }); + + it('should show an error message if the said account is being locked', async () => { + const { fixture, getByText, findUsernameService } = await setup(); + + spyOn(findUsernameService, 'findUserAccount').and.returnValue(of({ status: 'AccountLocked' })); + + await fillInAndSubmitForm('Test User', 'A1234567', 'test@example.com'); + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + + expect( + getByText('There is a problem with your account, please contact the Support Team on 0113 241 0969'), + ).toBeTruthy(); + }); + }); + }); + }); + + describe('Find username', () => { + const setupAndProceedToFindUsername = async () => { + const setuptools = await setup(); + + const { fixture, getByRole } = setuptools; + + userEvent.type(getByRole('textbox', { name: 'Name' }), 'Test User'); + userEvent.type(getByRole('textbox', { name: 'Workplace ID or postcode' }), 'A1234567'); + userEvent.type(getByRole('textbox', { name: 'Email address' }), 'test@example.com'); + + userEvent.click(getByRole('button', { name: 'Find account' })); + fixture.detectChanges(); + await fixture.whenStable(); + + return setuptools; + }; + + describe('rendering', () => { + it('should show the security question of the user', async () => { + const { getByText } = await setupAndProceedToFindUsername(); + + expect(getByText('Your security question')).toBeTruthy(); + expect(getByText('You entered this question when you created your account.')).toBeTruthy(); + expect(getByText('Question')).toBeTruthy(); + expect(getByText(mockTestUser.securityQuestion)).toBeTruthy(); + }); + + it('should show a text input for answer', async () => { + const { getByText, getByRole } = await setupAndProceedToFindUsername(); + + expect(getByText("What's the answer to your security question?")).toBeTruthy(); + expect(getByText('Answer')).toBeTruthy(); + expect(getByRole('textbox', { name: "What's the answer to your security question?" })).toBeTruthy(); + }); + + it('should show a reveal text of "Cannot remember the answer?"', async () => { + const { getByTestId } = await setupAndProceedToFindUsername(); + + const revealTextElement = getByTestId('reveal-text'); + const hiddenText = 'Call the ASC-WDS Support Team on 0113 241 0969 for help.'; + + expect(revealTextElement).toBeTruthy(); + expect(within(revealTextElement).getByText('Cannot remember the answer?')).toBeTruthy(); + expect(revealTextElement.textContent).toContain(hiddenText); + }); + + it('should render a "Find username" CTA button and a "Back to sign in" link', async () => { + const { getByRole, getByText } = await setupAndProceedToFindUsername(); + + expect(getByRole('button', { name: 'Find username' })).toBeTruthy(); + + const backToSignIn = getByText('Back to sign in'); + expect(backToSignIn).toBeTruthy(); + expect(backToSignIn.getAttribute('href')).toEqual('/login'); + }); + }); + + describe('submit form and validation', () => { + it('should call findUsernameService () on submit', async () => { + const { fixture, findUsernameService, getByRole } = await setupAndProceedToFindUsername(); + + spyOn(findUsernameService, 'findUsername').and.callThrough(); + + userEvent.type(getByRole('textbox', { name: "What's the answer to your security question?" }), 'Blue'); + userEvent.click(getByRole('button', { name: 'Find username' })); + + fixture.detectChanges(); + + expect(findUsernameService.findUsername).toHaveBeenCalledWith({ + uid: mockTestUser.accountUid, + securityQuestionAnswer: 'Blue', + }); + }); + + it('should set the retrieved username to service and navigate to username-found page if answer is correct', async () => { + const { fixture, getByRole, routerSpy, findUsernameService } = await setupAndProceedToFindUsername(); + + userEvent.type( + getByRole('textbox', { name: "What's the answer to your security question?" }), + mockTestUser.securityQuestionAnswer, + ); + userEvent.click(getByRole('button', { name: 'Find username' })); + + fixture.detectChanges(); + + expect(routerSpy).toHaveBeenCalledWith(['/username-found']); + expect(findUsernameService.usernameFound).toEqual(mockTestUser.username); + }); + + it('should show an error message if answer is incorrect', async () => { + const { fixture, getByRole, getByText, routerSpy, findUsernameService } = await setupAndProceedToFindUsername(); + + userEvent.type( + getByRole('textbox', { name: "What's the answer to your security question?" }), + 'some wrong answer', + ); + userEvent.click(getByRole('button', { name: 'Find username' })); + + fixture.detectChanges(); + + expect(getByText('Your answer does not match the one we have for your account.')).toBeTruthy(); + expect(getByText("You've 4 more chances to get your security question right.")).toBeTruthy(); + + expect(routerSpy).not.toHaveBeenCalled(); + expect(findUsernameService.usernameFound).toEqual(null); + }); + + it('should show a different error message when only 1 chance remain', async () => { + const { fixture, getByRole, getByText, findUsernameService } = await setupAndProceedToFindUsername(); + spyOn(findUsernameService, 'findUsername').and.returnValue(of({ answerCorrect: false, remainingAttempts: 1 })); + + userEvent.type( + getByRole('textbox', { name: "What's the answer to your security question?" }), + 'some wrong answer', + ); + userEvent.click(getByRole('button', { name: 'Find username' })); + + fixture.detectChanges(); + + expect(getByText('Your answer does not match the one we have for your account.')).toBeTruthy(); + expect(getByText("You've 1 more chance to get your security question right.")).toBeTruthy(); + expect(getByText("You'll need to call the Support Team if you get it wrong again.")).toBeTruthy(); + }); + + it('should navigate to "security-question-answer-not-match" page when remaining attempts = 0', async () => { + const { fixture, getByRole, findUsernameService, routerSpy } = await setupAndProceedToFindUsername(); + spyOn(findUsernameService, 'findUsername').and.returnValue(of({ answerCorrect: false, remainingAttempts: 0 })); + + userEvent.type( + getByRole('textbox', { name: "What's the answer to your security question?" }), + 'some wrong answer', + ); + userEvent.click(getByRole('button', { name: 'Find username' })); + + fixture.detectChanges(); + + expect(routerSpy).toHaveBeenCalledWith(['/security-question-answer-not-match']); + }); + + describe('error', () => { + it('should show an error message if answer is blank', async () => { + const { fixture, getByRole, getByText, getAllByText } = await setupAndProceedToFindUsername(); + userEvent.click(getByRole('button', { name: 'Find username' })); + + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + expect(getAllByText('Enter the answer to your security question')).toHaveSize(2); + }); + }); + }); + }); +}); diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.ts new file mode 100644 index 0000000000..ffaf31e982 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { AccountFound } from '@core/services/find-username.service'; + +import { FindAccountComponent } from './find-account/find-account.component'; +import { FindUsernameComponent } from './find-username/find-username.component'; + +@Component({ + selector: 'app-forgot-your-username', + templateUrl: './forgot-your-username.component.html', +}) +export class ForgotYourUsernameComponent implements OnInit { + public currentForm: FindAccountComponent | FindUsernameComponent; + public accountUid: string; + public securityQuestion: string; + + constructor(private cd: ChangeDetectorRef) {} + + ngOnInit(): void {} + + public setCurrentForm(childForm: FindAccountComponent | FindUsernameComponent): void { + this.currentForm = childForm; + this.cd.detectChanges(); + } + + public onAccountFound({ accountUid, securityQuestion }: AccountFound): void { + this.accountUid = accountUid; + this.securityQuestion = securityQuestion; + } +} diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.html new file mode 100644 index 0000000000..afedce52a4 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.html @@ -0,0 +1,16 @@ +
+ Error +

None of your security question answers matched the one we have for your account

+
+ +
+
+

Call the ASC-WDS Support Team on 0113 241 0969.

+

+ We're available Monday to Friday, 9am to 5pm (not including bank holidays). +

+ +
+
diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.spec.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.spec.ts new file mode 100644 index 0000000000..63e4477546 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.spec.ts @@ -0,0 +1,58 @@ +import { getTestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SharedModule } from '@shared/shared.module'; +import { render } from '@testing-library/angular'; + +import { SecurityQuestionAnswerNotMatchComponent } from './security-question-answer-not-match.component'; + +describe('SecurityQuestionAnswerNotMatchComponent', () => { + const setup = async () => { + const setupTools = await render(SecurityQuestionAnswerNotMatchComponent, { + imports: [SharedModule, RouterTestingModule], + providers: [], + }); + + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + const router = injector.inject(Router) as Router; + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + return { ...setupTools, component, routerSpy }; + }; + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should show a page heading', async () => { + const { getByText } = await setup(); + + const expectedHeadingText = 'None of your security question answers matched the one we have for your account'; + expect(getByText(expectedHeadingText)).toBeTruthy(); + }); + + it('should show a message about contacting support team', async () => { + const { getByText } = await setup(); + + const expectedContent = [ + 'Call the ASC-WDS Support Team on', + '0113 241 0969', + "We're available Monday to Friday, 9am to 5pm (not including bank holidays).", + ]; + + expectedContent.forEach((text) => { + expect(getByText(text, { exact: false })).toBeTruthy(); + }); + }); + + it('should show a "Back to sign in" button', async () => { + const { getByText } = await setup(); + + const backToSignin = getByText('Back to sign in'); + expect(backToSignin).toBeTruthy(); + expect(backToSignin.getAttribute('href')).toEqual('/login'); + }); +}); diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.ts new file mode 100644 index 0000000000..db824571c8 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-security-question-answer-not-match', + templateUrl: './security-question-answer-not-match.component.html', +}) +export class SecurityQuestionAnswerNotMatchComponent { + constructor() {} +} diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.html b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.html new file mode 100644 index 0000000000..a9bfe5837a --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.html @@ -0,0 +1,16 @@ +
+ Error +

We did not find your account

+
+ +
+
+

Call the ASC-WDS Support Team on 0113 241 0969.

+

+ We're available Monday to Friday, 9am to 5pm (not including bank holidays). +

+ +
+
diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.spec.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.spec.ts new file mode 100644 index 0000000000..c8a51878bc --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.spec.ts @@ -0,0 +1,58 @@ +import { getTestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SharedModule } from '@shared/shared.module'; +import { render } from '@testing-library/angular'; + +import { UserAccountNotFoundComponent } from './user-account-not-found.component'; + +describe('UserAccountNotFoundComponent', () => { + const setup = async () => { + const setupTools = await render(UserAccountNotFoundComponent, { + imports: [SharedModule, RouterTestingModule], + providers: [], + }); + + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + const router = injector.inject(Router) as Router; + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + return { ...setupTools, component, routerSpy }; + }; + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should show a page heading', async () => { + const { getByText } = await setup(); + + const expectedHeadingText = 'We did not find your account'; + expect(getByText(expectedHeadingText)).toBeTruthy(); + }); + + it('should show a message about contacting support team', async () => { + const { getByText } = await setup(); + + const expectedContent = [ + 'Call the ASC-WDS Support Team on', + '0113 241 0969', + "We're available Monday to Friday, 9am to 5pm (not including bank holidays).", + ]; + + expectedContent.forEach((text) => { + expect(getByText(text, { exact: false })).toBeTruthy(); + }); + }); + + it('should show a "Back to sign in" button', async () => { + const { getByText } = await setup(); + + const backToSignin = getByText('Back to sign in'); + expect(backToSignin).toBeTruthy(); + expect(backToSignin.getAttribute('href')).toEqual('/login'); + }); +}); diff --git a/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.ts b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.ts new file mode 100644 index 0000000000..1b6bf224d0 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-user-account-not-found', + templateUrl: './user-account-not-found.component.html', +}) +export class UserAccountNotFoundComponent {} diff --git a/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.html b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.html new file mode 100644 index 0000000000..59ecc0d729 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.html @@ -0,0 +1,28 @@ + +
+

+ We’ve found your username +

+
+ Your username is +
{{ username }}
+
+
+ +
+ +
+
+ + + + diff --git a/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.scss b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.scss new file mode 100644 index 0000000000..3296d00f22 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.scss @@ -0,0 +1,8 @@ +.panel { + width: 64%; +} + +.green-tick-icon { + height: 28px; + vertical-align: baseline; +} diff --git a/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.spec.ts b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.spec.ts new file mode 100644 index 0000000000..ebb4af802e --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.spec.ts @@ -0,0 +1,94 @@ +import { fireEvent, render, within } from '@testing-library/angular'; +import { UsernameFoundComponent } from './username-found.component'; +import { getTestBed } from '@angular/core/testing'; +import { Router, RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FindUsernameService } from '@core/services/find-username.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { PageNoLongerAvailableComponent } from '@core/components/error/page-no-longer-available/page-no-longer-available.component'; + +describe('UsernameFoundComponent', () => { + const setup = async (overrides: any = {}) => { + const setupTools = await render(UsernameFoundComponent, { + imports: [RouterModule, RouterTestingModule, HttpClientTestingModule], + declarations: [PageNoLongerAvailableComponent], + providers: [ + { + provide: FindUsernameService, + useValue: { usernameFound: overrides.username }, + }, + ], + componentProperties: {}, + }); + + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + const router = injector.inject(Router) as Router; + + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + return { ...setupTools, component, routerSpy }; + }; + + it('should create UsernameFoundComponent', async () => { + const overrides = { + username: 'Bighitterhank1000', + }; + + const { component } = await setup(overrides); + + expect(component).toBeTruthy(); + }); + + it('should show the panel', async () => { + const overrides = { + username: 'Bighitterhank1000', + }; + + const { getByTestId } = await setup(overrides); + + const panel = getByTestId('panel'); + + expect(panel).toBeTruthy(); + expect(within(panel).getByText('We’ve found your username')); + }); + + it('should show the username', async () => { + const overrides = { + username: 'Bighitterhank1000', + }; + + const { getByTestId } = await setup(overrides); + + const panel = getByTestId('panel'); + expect(within(panel).getByText('Your username is')); + expect(within(panel).getByText('Bighitterhank1000')); + }); + + it('should go back to the sign in page when the button is clicked', async () => { + const overrides = { + username: 'Bighitterhank1000', + }; + + const { fixture, getByText, routerSpy } = await setup(overrides); + + const buttonText = getByText('Back to sign in'); + expect(buttonText).toBeTruthy(); + + fireEvent.click(buttonText); + fixture.detectChanges(); + + expect(routerSpy).toHaveBeenCalledWith(['/login']); + }); + + it('should show page no longer available if no username was found', async () => { + const overrides = { + username: null, + }; + + const { getByTestId } = await setup(overrides); + + expect(getByTestId('page-no-longer-available')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.ts b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.ts new file mode 100644 index 0000000000..7d5ea43b71 --- /dev/null +++ b/frontend/src/app/features/forgot-your-username-or-password/username-found/username-found.component.ts @@ -0,0 +1,29 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { FindUsernameService } from '@core/services/find-username.service'; + +@Component({ + selector: 'app-username-found', + templateUrl: './username-found.component.html', + styleUrls: ['./username-found.component.scss'], +}) +export class UsernameFoundComponent implements OnInit { + public username: string; + public isUsernameFound: boolean; + + constructor(private router: Router, private findUsernameService: FindUsernameService) {} + + ngOnInit(): void { + this.getUsernameFound(); + this.isUsernameFound = this.username !== null; + } + + public getUsernameFound(): void { + this.username = this.findUsernameService.usernameFound; + } + + public backToSignInPage(event: Event): void { + event.preventDefault(); + this.router.navigate(['/login']); + } +} diff --git a/frontend/src/app/features/login/login.component.html b/frontend/src/app/features/login/login.component.html index 4a0117e46d..379fdec5d2 100644 --- a/frontend/src/app/features/login/login.component.html +++ b/frontend/src/app/features/login/login.component.html @@ -3,10 +3,11 @@ [formErrorsMap]="formErrorsMap" [serverError]="serverError" [form]="form" + [showServerErrorAsLink]="showServerErrorAsLink" > -
+

Sign in

@@ -17,15 +18,27 @@

Sign in

[class.govuk-form-group--error]="(form.get('username').errors || serverError) && submitted" > +
+ You cannot use an email address to sign in +
+ - Error: - {{ getFormErrorMessage('username', 'required') }} + + Error: + {{ getFormErrorMessage('username', 'required') }} + + + + Error: + {{ getFormErrorMessage('username', 'atSignInUsername') }} + + Sign in > Error: {{ getFormErrorMessage('password', 'required') }} + + {{ showPassword ? 'Hide' : 'Show' }} password
- +
diff --git a/frontend/src/app/features/login/login.component.scss b/frontend/src/app/features/login/login.component.scss new file mode 100644 index 0000000000..faf12e9c51 --- /dev/null +++ b/frontend/src/app/features/login/login.component.scss @@ -0,0 +1,5 @@ +@import 'govuk-frontend/govuk/base'; + +.asc-colour-black { + color: $govuk-text-colour; +} diff --git a/frontend/src/app/features/login/login.component.spec.ts b/frontend/src/app/features/login/login.component.spec.ts index d41d0a7544..ff76efc127 100644 --- a/frontend/src/app/features/login/login.component.spec.ts +++ b/frontend/src/app/features/login/login.component.spec.ts @@ -9,17 +9,20 @@ import { MockAuthService } from '@core/test-utils/MockAuthService'; import { MockUserService } from '@core/test-utils/MockUserService'; import { FeatureFlagsService } from '@shared/services/feature-flags.service'; import { SharedModule } from '@shared/shared.module'; -import { render } from '@testing-library/angular'; +import { fireEvent, render, within } from '@testing-library/angular'; import { throwError } from 'rxjs'; import { LoginComponent } from './login.component'; +import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; +import userEvent from '@testing-library/user-event'; describe('LoginComponent', () => { async function setup(isAdmin = false, employerTypeSet = true, isAuthenticated = true) { - const { fixture, getAllByText, getByText, queryByText, getByTestId } = await render(LoginComponent, { - imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule], + const setupTools = await render(LoginComponent, { + imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], providers: [ FeatureFlagsService, + UntypedFormBuilder, { provide: AuthService, useFactory: MockAuthService.factory(true, isAdmin, employerTypeSet), @@ -38,7 +41,7 @@ describe('LoginComponent', () => { spy.and.returnValue(Promise.resolve(true)); const authService = injector.inject(AuthService) as AuthService; - let authSpy; + let authSpy: any; if (isAuthenticated) { authSpy = spyOn(authService, 'authenticate'); authSpy.and.callThrough(); @@ -53,14 +56,13 @@ describe('LoginComponent', () => { authSpy = spyOn(authService, 'authenticate').and.returnValue(throwError(mockErrorResponse)); } + const fixture = setupTools.fixture; const component = fixture.componentInstance; + return { component, fixture, - getAllByText, - getByText, - queryByText, - getByTestId, + ...setupTools, spy, authSpy, }; @@ -71,6 +73,82 @@ describe('LoginComponent', () => { expect(component).toBeTruthy(); }); + describe('username', () => { + it('should show the username hint', async () => { + const { getByTestId } = await setup(); + + const usernameHint = getByTestId('username-hint'); + const hintText = 'You cannot use an email address to sign in'; + + expect(within(usernameHint).getByText(hintText)).toBeTruthy(); + }); + }); + + describe('password', () => { + it('should set the password as password field (to hide input) on page load', async () => { + const { getByTestId } = await setup(); + + const passwordInput = getByTestId('password'); + + expect(passwordInput.getAttribute('type')).toEqual('password'); + }); + + it("should show the password as text field after user clicks 'Show password'", async () => { + const { fixture, getByTestId, getByText } = await setup(); + + const showToggleText = 'Show password'; + + fireEvent.click(getByText(showToggleText)); + fixture.detectChanges(); + + const passwordInput = getByTestId('password'); + + expect(passwordInput.getAttribute('type')).toEqual('text'); + }); + + it("should initially show 'Show password' text for the password toggle", async () => { + const { getByTestId } = await setup(); + + const passwordToggle = getByTestId('password-toggle'); + const toggleText = 'Show password'; + + expect(within(passwordToggle).getByText(toggleText)).toBeTruthy(); + }); + + it("should show 'Hide password' text for the password toggle when 'Show password' is clicked", async () => { + const { fixture, getByTestId, getByText } = await setup(); + + const passwordToggle = getByTestId('password-toggle'); + const showToggleText = 'Show password'; + const hideToggleText = 'Hide password'; + + fireEvent.click(getByText(showToggleText)); + fixture.detectChanges(); + + expect(within(passwordToggle).getByText(hideToggleText)).toBeTruthy(); + }); + }); + + it('should show the link to forgot username or password', async () => { + const { getByTestId } = await setup(); + + const forgotUsernamePasswordText = 'Forgot your username or password?'; + const forgotUsernamePasswordLink = getByTestId('forgot-username-password'); + + expect(within(forgotUsernamePasswordLink).getByText(forgotUsernamePasswordText)).toBeTruthy(); + expect(forgotUsernamePasswordLink.getAttribute('href')).toEqual('/forgot-your-username-or-password'); + }); + + it('should show the link to create an account', async () => { + const { getByTestId } = await setup(); + + const createAccountText = 'Create an account'; + const createAccountLink = getByTestId('create-account'); + + expect(within(createAccountLink).getByText(createAccountText)).toBeTruthy(); + expect(createAccountLink.getAttribute('href')).toEqual('/registration/create-account'); + }); + it('should send you to dashboard on login as user', async () => { const { component, fixture, spy, authSpy } = await setup(); @@ -165,5 +243,42 @@ describe('LoginComponent', () => { fixture.detectChanges(); expect(getAllByText('Your username or your password is incorrect')).toBeTruthy(); }); + + it('should focus on the first input box when the invalid username/password message is clicked', async () => { + const { component, fixture, getAllByText, getByRole } = await setup(false, false, false); + + component.form.setValue({ username: '1', password: '1' }); + component.onSubmit(); + + fixture.detectChanges(); + const errorMessageInSummaryBox = getAllByText('Your username or your password is incorrect')[0]; + const usernameInputBoxEl = getByRole('textbox', { name: 'Username' }); + const focusSpy = spyOn(usernameInputBoxEl, 'focus'); + + userEvent.click(errorMessageInSummaryBox); + await fixture.whenStable(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('should not let you sign in with a username with special characters', async () => { + const { component, fixture, getAllByText, getByTestId } = await setup(); + + const signInButton = within(getByTestId('signinButton')).getByText('Sign in'); + const form = component.form; + + component.form.markAsDirty(); + form.controls['username'].setValue('username@123.com'); + form.controls['username'].markAsDirty(); + component.form.get('password').setValue('1'); + component.form.get('password').markAsDirty(); + + fireEvent.click(signInButton); + fixture.detectChanges(); + + expect(form.invalid).toBeTruthy(); + expect( + getAllByText("You've entered an @ symbol (remember, your username cannot be an email address)").length, + ).toBe(2); + }); }); }); diff --git a/frontend/src/app/features/login/login.component.ts b/frontend/src/app/features/login/login.component.ts index e0e48e232c..1024afe6fa 100644 --- a/frontend/src/app/features/login/login.component.ts +++ b/frontend/src/app/features/login/login.component.ts @@ -1,6 +1,13 @@ import { HttpErrorResponse } from '@angular/common/http'; import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { + AbstractControl, + UntypedFormBuilder, + UntypedFormGroup, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms'; import { Router } from '@angular/router'; import { ErrorDefinition, ErrorDetails } from '@core/model/errorSummary.model'; import { AuthService } from '@core/services/auth.service'; @@ -14,6 +21,7 @@ import { Subscription } from 'rxjs'; @Component({ selector: 'app-login', templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], }) export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild('formEl') formEl: ElementRef; @@ -23,6 +31,8 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { public formErrorsMap: Array; public serverErrorsMap: Array; public serverError: string; + public showPassword: boolean = false; + public showServerErrorAsLink: boolean = true; constructor( private idleService: IdleService, @@ -36,8 +46,20 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { ngOnInit() { this.form = this.formBuilder.group({ - username: [null, { validators: [Validators.required], updateOn: 'submit' }], - password: [null, { validators: [Validators.required], updateOn: 'submit' }], + username: [ + null, + { + validators: [Validators.required, this.checkUsernameForAtSign()], + updateOn: 'submit', + }, + ], + password: [ + null, + { + validators: [Validators.required], + updateOn: 'submit', + }, + ], }); this.setupFormErrorsMap(); @@ -52,6 +74,20 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { this.subscriptions.unsubscribe(); } + public checkUsernameForAtSign(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + + if (!value) { + return null; + } + + const userNameHasAtSign = /@/.test(value); + + return userNameHasAtSign ? { atSignInUsername: true } : null; + }; + } + public setupFormErrorsMap(): void { this.formErrorsMap = [ { @@ -61,6 +97,10 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { name: 'required', message: 'Enter your username', }, + { + name: 'atSignInUsername', + message: "You've entered an @ symbol (remember, your username cannot be an email address)", + }, ], }, { @@ -87,11 +127,11 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { }, { name: 409, - message: 'There is a problem with your account, please contact support on 0113 241 0969', + message: 'There is a problem with your account, please contact the Support Team on 0113 241 0969', }, { name: 405, - message: 'Your registration request is awaiting approval, please contact support on 0113 241 0969', + message: 'Your registration request is awaiting approval, please contact the Support Team on 0113 241 0969', }, ]; } @@ -150,8 +190,14 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { }, (error: HttpErrorResponse) => { this.serverError = this.errorSummaryService.getServerErrorMessage(error.status, this.serverErrorsMap); + this.showServerErrorAsLink = error.status === 401; }, ), ); } + + public setShowPassword(event: Event): void { + event.preventDefault(); + this.showPassword = !this.showPassword; + } } diff --git a/frontend/src/app/shared/components/error-summary/error-summary.component.html b/frontend/src/app/shared/components/error-summary/error-summary.component.html index 3a743a4060..5766164c62 100644 --- a/frontend/src/app/shared/components/error-summary/error-summary.component.html +++ b/frontend/src/app/shared/components/error-summary/error-summary.component.html @@ -26,7 +26,18 @@

There is a probl
  • - + + + + +

    {{ serverError }}

    +
  • diff --git a/frontend/src/app/shared/components/error-summary/error-summary.component.spec.ts b/frontend/src/app/shared/components/error-summary/error-summary.component.spec.ts index 53ecbc346e..96574c8776 100644 --- a/frontend/src/app/shared/components/error-summary/error-summary.component.spec.ts +++ b/frontend/src/app/shared/components/error-summary/error-summary.component.spec.ts @@ -6,9 +6,33 @@ import { render } from '@testing-library/angular'; import { ErrorSummaryComponent } from './error-summary.component'; describe('ErrorSummaryComponent', () => { + const setup = async (override: any = {}) => { + spyOn(ErrorSummaryComponent.prototype, 'getFormErrorMessage').and.stub(); + + const setupTools = await render(ErrorSummaryComponent, { + imports: [SharedModule, ReactiveFormsModule], + providers: [ + { + provide: ActivatedRoute, + useValue: {}, + }, + ], + componentProperties: { + formErrorsMap: [], + ...override, + }, + }); + + const component = setupTools.fixture.componentInstance; + + return { + component, + ...setupTools, + }; + }; describe('getFormErrors', () => { describe('for the error of any element in a formArray', () => { - const setup = async (override: any = {}) => { + const buildMockForm = () => { const formBuilder = new UntypedFormBuilder(); const mockFormArray = formBuilder.array([]); const mockInputValues = [0, 0, 0]; @@ -16,36 +40,14 @@ describe('ErrorSummaryComponent', () => { mockFormArray.push(formBuilder.control(value, [Validators.min(1), Validators.max(10)])), ); const mockForm = formBuilder.group({ numbers: mockFormArray }); - - spyOn(ErrorSummaryComponent.prototype, 'getFormErrorMessage').and.stub(); - - const { fixture } = await render(ErrorSummaryComponent, { - imports: [SharedModule, ReactiveFormsModule], - providers: [ - { - provide: ActivatedRoute, - useValue: {}, - }, - ], - componentProperties: { - form: mockForm, - formErrorsMap: [], - ...override, - }, - }); - - const component = fixture.componentInstance; - - return { - component, - }; + return mockForm; }; it('should not include an index number in the item key by default', async () => { // This is the existing behaviour before change. // Can't be sure if any component is depending on this, // so keep this behaviour as the default, and setup with addIndexKeyToFormArrayErrors=true only when needed - const { component } = await setup(); + const { component } = await setup({ form: buildMockForm() }); component.form.setValue({ numbers: [0, 5, 20] }); expect(component.errors).toEqual([ @@ -55,7 +57,7 @@ describe('ErrorSummaryComponent', () => { }); it('should includes an index number in the item key if addIndexKeyToFormArrayErrors is true', async () => { - const { component } = await setup({ addIndexKeyToFormArrayErrors: true }); + const { component } = await setup({ form: buildMockForm(), addIndexKeyToFormArrayErrors: true }); component.form.setValue({ numbers: [0, 5, 20] }); expect(component.errors).toEqual([ { item: 'numbers.0', errors: ['min'] }, @@ -64,4 +66,34 @@ describe('ErrorSummaryComponent', () => { }); }); }); + + describe('when serverError is provided', () => { + const buildMockForm = () => { + const formBuilder = new UntypedFormBuilder(); + return formBuilder.group({ textField: ['', null] }); + }; + + it('should show a link with the server error message by default', async () => { + const { getByRole } = await setup({ + form: buildMockForm(), + serverError: 'Some error message from server', + }); + + const errorMessage = getByRole('link', { name: 'Some error message from server' }); + expect(errorMessage).toBeTruthy(); + expect(errorMessage.getAttribute('href')).toEqual('/#server-error'); + }); + + it('should show the server error message as non-clickable text if showServerErrorAsLink is false', async () => { + const { getByText, queryByRole } = await setup({ + form: buildMockForm(), + serverError: 'Some error message from server', + showServerErrorAsLink: false, + }); + + expect(getByText('Some error message from server')).toBeTruthy(); + expect(queryByRole('link', { name: 'Some error message from server' })).toBeFalsy(); + expect(getByText('Some error message from server').getAttribute('href')).toEqual(null); + }); + }); }); diff --git a/frontend/src/app/shared/components/error-summary/error-summary.component.ts b/frontend/src/app/shared/components/error-summary/error-summary.component.ts index b25c9e5305..7f788edb0c 100644 --- a/frontend/src/app/shared/components/error-summary/error-summary.component.ts +++ b/frontend/src/app/shared/components/error-summary/error-summary.component.ts @@ -16,6 +16,7 @@ export class ErrorSummaryComponent implements OnInit, OnDestroy { @Input() public serverError?: string; @Input() public customErrors?: Array; @Input() addIndexKeyToFormArrayErrors: boolean = false; + @Input() showServerErrorAsLink: boolean = true; @ViewChild('errorSummary', { static: true }) private errorSummaryElement: ElementRef; private subscriptions: Subscription = new Subscription(); public errors: Array; diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 3278ea8678..c3927d9f04 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -5,6 +5,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { CannotCreateAccountComponent } from '@core/components/error/cannot-create-account/cannot-create-account.component'; import { PageNotFoundComponent } from '@core/components/error/page-not-found/page-not-found.component'; +import { PageNoLongerAvailableComponent } from '@core/components/error/page-no-longer-available/page-no-longer-available.component'; import { ArticleListResolver } from '@core/resolvers/article-list.resolver'; import { PageResolver } from '@core/resolvers/page.resolver'; import { DialogService } from '@core/services/dialog.service'; @@ -210,6 +211,7 @@ import { WorkplaceNameAddress } from './components/workplace-name-address/workpl WdfStaffMismatchMessageComponent, CheckCQCDetailsComponent, PageNotFoundComponent, + PageNoLongerAvailableComponent, ArticleListComponent, PageComponent, FirstErrorPipe, @@ -338,6 +340,7 @@ import { WorkplaceNameAddress } from './components/workplace-name-address/workpl MoveWorkplaceDialogComponent, CheckCQCDetailsComponent, PageNotFoundComponent, + PageNoLongerAvailableComponent, ArticleListComponent, PageComponent, FirstErrorPipe, diff --git a/frontend/src/assets/scss/components/_buttons.scss b/frontend/src/assets/scss/components/_buttons.scss index c1cb0cdb5f..be7740796c 100644 --- a/frontend/src/assets/scss/components/_buttons.scss +++ b/frontend/src/assets/scss/components/_buttons.scss @@ -54,6 +54,11 @@ $button-shadow-size: $govuk-border-width-form-element; } } +.govuk-button-group--gap-between { + @extend .govuk-button-group; + column-gap: govuk-spacing(8) * 2; +} + .govuk-button { &.govuk-button--link { background: none; diff --git a/frontend/src/assets/scss/components/_panel.scss b/frontend/src/assets/scss/components/_panel.scss index 37fe109ebb..9690b1e341 100644 --- a/frontend/src/assets/scss/components/_panel.scss +++ b/frontend/src/assets/scss/components/_panel.scss @@ -50,3 +50,28 @@ .govuk-panel--gray { background-color: govuk-colour('light-grey'); } + +.govuk-panel--with-icon { + background-color: govuk-colour('light-grey'); + text-align: left; + display: flex; + align-items: baseline; + + padding-left: 18px; + column-gap: 5px; + margin-bottom: govuk-spacing(6); + + > img { + height: 20px; + width: 20px; + + @media only screen and (min-width: 641px) { + height: 28px; + width: 28px; + } + } + + > h1 { + margin-bottom: 0; + } +} diff --git a/frontend/src/assets/scss/modules/_utils.scss b/frontend/src/assets/scss/modules/_utils.scss index 20daf587b7..6a24984aae 100644 --- a/frontend/src/assets/scss/modules/_utils.scss +++ b/frontend/src/assets/scss/modules/_utils.scss @@ -105,6 +105,12 @@ .govuk__nowrap { white-space: nowrap; } +.govuk__nowrap-responsive { + @media screen and (min-width: 641px) { + white-space: nowrap; + } +} + .govuk-util__vertical-align-top { vertical-align: top; } @@ -139,3 +145,7 @@ .asc__preline { white-space: pre-line; } + +.asc__negative-margin-right-4 { + margin-right: -1 * govuk-spacing(4); +}