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 @@
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 @@
+
+
+
+
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).
+
+
+
+
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 @@
+
+
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 @@
+
+
+
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 @@
+
+
+
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 }}
+
+
+
+
+
+ Back to sign in
+
+
+
+
+
+
+
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"
>
-
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);
+}