diff --git a/package-lock.json b/package-lock.json index ec981ecf..c0b5389f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4421,9 +4421,9 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://dfe-ssp.pkgs.visualstudio.com/_packaging/Signin-Default/npm/registry/express/-/express-4.21.1.tgz", - "integrity": "sha1-na5d2oMvFrTuyUGk5EqonsSBsoE=", + "version": "4.21.2", + "resolved": "https://dfe-ssp.pkgs.visualstudio.com/_packaging/Signin-Default/npm/registry/express/-/express-4.21.2.tgz", + "integrity": "sha1-zyUOSDYhdOrWzqSlZqvvAWLB7DI=", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4444,7 +4444,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -4459,6 +4459,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-ejs-layouts": { @@ -7907,9 +7911,9 @@ "integrity": "sha1-+8EUtgykKzDZ2vWFjkvWi77bZzU=" }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://dfe-ssp.pkgs.visualstudio.com/_packaging/Signin-Default/npm/registry/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha1-Z+kQjFwFUbnlMmBkOH3kdjxNX4s=" + "version": "0.1.12", + "resolved": "https://dfe-ssp.pkgs.visualstudio.com/_packaging/Signin-Default/npm/registry/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha1-1eGhLkeKl21DLvPFjVNLmSMWS7c=" }, "node_modules/pause": { "version": "0.0.1", diff --git a/src/app/users/getBulkUserActions.js b/src/app/users/getBulkUserActions.js new file mode 100644 index 00000000..f3c3b3ee --- /dev/null +++ b/src/app/users/getBulkUserActions.js @@ -0,0 +1,11 @@ +const getBulkUserActions = (req, res) => { + const model = { + csrfToken: req.csrfToken(), + emails: '', + validationMessages: {}, + }; + + res.render('users/views/bulkUserActions', model); +}; + +module.exports = getBulkUserActions; diff --git a/src/app/users/getBulkUserActionsEmails.js b/src/app/users/getBulkUserActionsEmails.js new file mode 100644 index 00000000..5b3c2704 --- /dev/null +++ b/src/app/users/getBulkUserActionsEmails.js @@ -0,0 +1,29 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-restricted-syntax */ +const { searchForBulkUsersPage } = require('./utils'); + +const getBulkUserActionsEmails = async (req, res) => { + const emails = req.session.emails; + if (!emails) { + return res.redirect('/users'); + } + const users = []; + + const emailsArray = emails.split(','); + for (const email of emailsArray) { + const result = await searchForBulkUsersPage(email.trim()); + for (const user of result.users) { + users.push(user); + } + } + + const model = { + csrfToken: req.csrfToken(), + users, + validationMessages: {}, + }; + + res.render('users/views/bulkUserActionsEmails', model); +}; + +module.exports = getBulkUserActionsEmails; diff --git a/src/app/users/index.js b/src/app/users/index.js index 97dbf8cd..c36ad2cc 100644 --- a/src/app/users/index.js +++ b/src/app/users/index.js @@ -29,6 +29,10 @@ const getOrganisationPermissions = require('./getOrganisationPermissions'); const postOrganisationPermissions = require('./postOrganisationPermissions'); const getConfirmNewUser = require('./getConfirmNewUser'); const postConfirmNewUser = require('./postConfirmNewUser'); +const getBulkUserActions = require('./getBulkUserActions'); +const postBulkUserActions = require('./postBulkUserActions'); +const getBulkUserActionsEmails = require('./getBulkUserActionsEmails'); +const postBulkUserActionsEmails = require('./postBulkUserActionsEmails'); const postCancelChangeEmail = require('./postCancelChangeEmail'); const getConfirmAssociateOrganisation = require('./getConfirmAssociateOrganisation'); const postResendInvite = require('./postResendInvite'); @@ -39,8 +43,8 @@ const postDeleteOrganisation = require('./postDeleteOrganisation'); const getSecureAccess = require('./getSecureAccessDetails'); const postUpdateAuditLog = require('./postUpdateAuditLog'); const getManageConsoleServices = require('./getManageConsoleServices'); -const postManageConsoleRoles = require('./postManageConsoleRoles') -const { getManageConsoleRoles } = require('./getManageConsoleRoles') +const postManageConsoleRoles = require('./postManageConsoleRoles'); +const { getManageConsoleRoles } = require('./getManageConsoleRoles'); const { get: getAssociateServices, post: postAssociateServices } = require('./associateServices'); const { get: getSelectOrganisation, post: postSelectOrganisation } = require('./selectOrganisation'); const { get: getAssociateRoles, post: postAssociateRoles } = require('./associateRoles'); @@ -67,6 +71,10 @@ const users = (csrf) => { router.post('/organisation-permissions', csrf, asyncWrapper(postOrganisationPermissions)); router.get('/confirm-new-user', csrf, asyncWrapper(getConfirmNewUser)); router.post('/confirm-new-user', csrf, asyncWrapper(postConfirmNewUser)); + router.get('/bulk-user-actions', csrf, asyncWrapper(getBulkUserActions)); + router.post('/bulk-user-actions', csrf, asyncWrapper(postBulkUserActions)); + router.get('/bulk-user-actions/emails', csrf, asyncWrapper(getBulkUserActionsEmails)); + router.post('/bulk-user-actions/emails', csrf, asyncWrapper(postBulkUserActionsEmails)); router.get('/:uid', asyncWrapper((req, res) => { res.redirect(`/users/${req.params.uid}/organisations`); diff --git a/src/app/users/postBulkUserActions.js b/src/app/users/postBulkUserActions.js new file mode 100644 index 00000000..51336b12 --- /dev/null +++ b/src/app/users/postBulkUserActions.js @@ -0,0 +1,47 @@ +/* eslint-disable no-restricted-syntax */ +const { emailPolicy } = require('login.dfe.validation'); +const { sendResult } = require('../../infrastructure/utils'); + +const validateInput = async (req) => { + const model = { + emails: req.body.emails || '', + validationMessages: {}, + }; + + if (!model.emails) { + model.validationMessages.emails = 'Please enter an email address'; + return model; + } + + // Removes any newline characters + model.emails = model.emails.replace(' ', ''); + model.emails = model.emails.replace(' ', ''); + + // Trim whitespace around each email provided and remove duplicates + const trimmedEmails = model.emails.split(',').map((email) => email.trim()); + const deduplicatedEmails = [...new Set(trimmedEmails)]; + + for (const email of deduplicatedEmails) { + if (!emailPolicy.doesEmailMeetPolicy(email)) { + model.validationMessages.emails = `Please enter a valid email address for ${email}`; + } + } + + // Reglue array together back into a comma separated string + model.emails = deduplicatedEmails.join(); + + return model; +}; + +const postBulkUserActions = async (req, res) => { + const model = await validateInput(req); + if (Object.keys(model.validationMessages).length > 0) { + model.csrfToken = req.csrfToken(); + return sendResult(req, res, 'users/views/bulkUserActions', model); + } + + req.session.emails = model.emails; + return res.redirect('bulk-user-actions/emails'); +}; + +module.exports = postBulkUserActions; diff --git a/src/app/users/postBulkUserActionsEmails.js b/src/app/users/postBulkUserActionsEmails.js new file mode 100644 index 00000000..4c098ac2 --- /dev/null +++ b/src/app/users/postBulkUserActionsEmails.js @@ -0,0 +1,123 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-restricted-syntax */ +const { sendResult } = require('../../infrastructure/utils'); +const { deactivate, deactivateInvite } = require('../../infrastructure/directories'); +const { + getUserDetailsById, + updateUserDetails, + waitForIndexToUpdate, + rejectOpenOrganisationRequestsForUser, + rejectOpenUserServiceRequestsForUser, + removeAllServicesForUser, + removeAllServicesForInvitedUser, + searchForBulkUsersPage, +} = require('./utils'); + +const validateInput = async (req) => { + const model = { + users: [], + validationMessages: {}, + }; + + const reqBody = req.body; + const res = Object.keys(reqBody).filter((v) => v.startsWith('user-')); + if (res.length === 0) { + model.validationMessages.users = 'At least 1 user needs to be ticked'; + } + + const isDeactivateTicked = reqBody['deactivate-users'] || false; + const isRemoveServicesAndRequestsTicked = reqBody['remove-services-and-requests'] || false; + if (!isDeactivateTicked && !isRemoveServicesAndRequestsTicked) { + model.validationMessages.actions = 'At least 1 action needs to be ticked'; + } + + return model; +}; + +const updateUserIndex = async (uid, correlationId) => { + const user = await getUserDetailsById(uid, correlationId); + user.status = { + id: 0, + description: 'Deactivated', + }; + + await updateUserDetails(user, correlationId); + await waitForIndexToUpdate(uid, (updated) => updated.status.id === 0); +}; + +const updateInvitedUserIndex = async (uid, correlationId) => { + const user = await getUserDetailsById(uid, correlationId); + user.status.id = -2; + user.status.description = 'Deactivated Invitation'; + + await updateUserDetails(user, correlationId); + await waitForIndexToUpdate(uid, (updated) => updated.status.id === -2); +}; + +const deactivateUser = async (req, id) => { + await deactivate(id, req.id); + await updateUserIndex(id, req.id); +}; + +const deactivateInvitedUser = async (req, userId) => { + await deactivateInvite(userId, 'Bulk user deactivation', req.id); + await updateInvitedUserIndex(userId, req.id); +}; + +const postBulkUserActionsEmails = async (req, res) => { + const model = await validateInput(req); + if (Object.keys(model.validationMessages).length > 0) { + model.csrfToken = req.csrfToken(); + + // Need to search for all the users again if there's an error. It's a little inefficient, + // but realistically this page won't be generating many errors so this should be fairly infrequent. + const emails = req.session.emails; + const emailsArray = emails.split(','); + for (const email of emailsArray) { + const result = await searchForBulkUsersPage(email); + for (const user of result.users) { + model.users.push(user); + } + } + return sendResult(req, res, 'users/views/bulkUserActionsEmails', model); + } + + const reqBody = req.body; + const isDeactivateTicked = reqBody['deactivate-users'] || false; + const isRemoveServicesAndRequestsTicked = reqBody['remove-services-and-requests'] || false; + + // Get all the inputs and figure out which users were ticked + const tickedUsers = Object.keys(reqBody).filter(v => v.startsWith('user-')); + + // eslint-disable-next-line no-restricted-syntax + for (const tickedUser of tickedUsers) { + // TODO add logging + const userId = reqBody[tickedUser]; + if (isDeactivateTicked) { + if (userId.startsWith('inv-')) { + await deactivateInvitedUser(req, userId); + } else { + await deactivateUser(req, userId); + } + } + + if (isRemoveServicesAndRequestsTicked) { + if (userId.startsWith('inv-')) { + await removeAllServicesForInvitedUser(userId, req); + } else { + await rejectOpenUserServiceRequestsForUser(userId, req); + await rejectOpenOrganisationRequestsForUser(userId, req); + await removeAllServicesForUser(userId, req); + } + } + } + + // Clean up session value + req.session.emails = ''; + const userText = tickedUsers.length > 1 ? 'users' : 'user'; + res.flash('info', `Requested actions performed successfully on ${tickedUsers.length} ${userText}`); + + return res.redirect('/users'); +}; + +module.exports = postBulkUserActionsEmails; diff --git a/src/app/users/postConfirmDeactivate.js b/src/app/users/postConfirmDeactivate.js index 8c7ac5d9..0b05e338 100644 --- a/src/app/users/postConfirmDeactivate.js +++ b/src/app/users/postConfirmDeactivate.js @@ -20,7 +20,6 @@ const updateUserIndex = async (uid, correlationId) => { }; await updateUserDetails(user, correlationId); - await waitForIndexToUpdate(uid, (updated) => updated.status.id === 0); }; diff --git a/src/app/users/postConfirmInvitationDeactivate.js b/src/app/users/postConfirmInvitationDeactivate.js index 5dc3b70f..59572998 100644 --- a/src/app/users/postConfirmInvitationDeactivate.js +++ b/src/app/users/postConfirmInvitationDeactivate.js @@ -1,10 +1,9 @@ /* eslint-disable no-restricted-syntax */ const logger = require('../../infrastructure/logger'); const { - getUserDetails, getUserDetailsById, updateUserDetails, waitForIndexToUpdate, + getUserDetails, getUserDetailsById, updateUserDetails, waitForIndexToUpdate, removeAllServicesForInvitedUser, } = require('./utils'); const { deactivateInvite } = require('../../infrastructure/directories'); -const { getServicesByInvitationId, removeServiceFromInvitation } = require('../../infrastructure/access'); const { sendResult } = require('../../infrastructure/utils'); const updateUserIndex = async (uid, correlationId) => { @@ -19,8 +18,7 @@ const updateUserIndex = async (uid, correlationId) => { const postConfirmDeactivate = async (req, res) => { const user = await getUserDetails(req); - const correlationId = req.id; - + if (req.body['select-reason'] && req.body['select-reason'] !== 'Select a reason' && req.body.reason.trim() === '') { req.body.reason = req.body['select-reason']; } else if (req.body['select-reason'] && req.body['select-reason'] !== 'Select a reason' && req.body.reason.length > 0) { @@ -40,14 +38,7 @@ const postConfirmDeactivate = async (req, res) => { await deactivateInvite(user.id, req.body.reason, req.id); await updateUserIndex(user.id, req.id); if (req.body['remove-services-from-invite']) { - logger.info(`Attemping to remove services from invite with id: ${req.params.uid}`, { correlationId }); - // No need to get the invitation to double check as getUserDetails does that already, if we're here then the invite definitely exists. - // getUserDetails parrots back the 'inv-' as its id instead of giving us the true one without the 'inv-' prefix. - const invitationServiceRecords = await getServicesByInvitationId(user.id.substr(4)) || []; - for (const serviceRecord of invitationServiceRecords) { - logger.info(`Deleting invitation service record for invitationId: ${serviceRecord.invitationId}, serviceId: ${serviceRecord.serviceId} and organisationId: ${serviceRecord.organisationIdId}`, { correlationId }); - removeServiceFromInvitation(serviceRecord.invitationId, serviceRecord.serviceId, serviceRecord.organisationId, req.id); - } + await removeAllServicesForInvitedUser(user.id, req); } logger.audit(`${req.user.email} (id: ${req.user.sub}) deactivated user invitation ${user.email} (id: ${user.id})`, { diff --git a/src/app/users/utils.js b/src/app/users/utils.js index eb8df8be..698583b2 100644 --- a/src/app/users/utils.js +++ b/src/app/users/utils.js @@ -10,9 +10,14 @@ const { } = require('./../../infrastructure/directories'); const { getServicesByUserId, - getServicesByInvitationId + getServicesByInvitationId, + getUserServiceRequestsByUserId, + removeServiceFromUser, + removeServiceFromInvitation, + updateUserServiceRequest } = require('./../../infrastructure/access'); const { getServiceById } = require('./../../infrastructure/applications'); +const { getPendingRequestsAssociatedWithUser, updateRequestById } = require('../../infrastructure/organisations'); const { mapUserStatus } = require('./../../infrastructure/utils'); const config = require('./../../infrastructure/config'); const sortBy = require('lodash/sortBy'); @@ -193,6 +198,51 @@ const search = async (req) => { }; }; +/** + * Modified user search used for the bulk user actions screen. + * + * @param email - A string representing the email that will be searched for + */ +const searchForBulkUsersPage = async (email) => { + let criteria = email.trim(); + const userRegex = /^[^±!£$%^&*+§¡€#¢§¶•ªº«\\/<>?:;|=,~"]{1,256}$/i; + + if (!criteria || criteria.length < 4) { + return { + validationMessages: { + criteria: 'Please enter at least 4 characters' + } + }; + } + if (!userRegex.test(criteria)) { + return { + validationMessages: { + criteria: 'Special characters cannot be used' + } + }; + } + + if (criteria.indexOf('-') !== -1) { + criteria = '"' + criteria + '"'; + } + const page = 1; + const sortBy = 'name'; + const sortAsc = 'asc'; + const filter = undefined; + + const results = await searchForUsers( + criteria + '*', + page, + sortBy, + sortAsc, + filter + ); + + return { + users: results.users + }; +}; + const getUserDetails = async (req) => { return getUserDetailsById(req.params.uid, req.id); }; @@ -345,12 +395,70 @@ const mapRole = (roleId) => { return { id: 0, description: 'End user' }; }; +const rejectOpenUserServiceRequestsForUser = async (userId, req) => { + const correlationId = req.id; + const userServiceRequests = await getUserServiceRequestsByUserId(userId) || []; + for (const serviceRequest of userServiceRequests) { + // Request status 0 is 'pending', 2 is 'overdue', 3 is 'no approvers' + if (serviceRequest.status === 0 || serviceRequest.status === 2 || serviceRequest.status === 3) { + logger.info(`Rejecting service request with id: ${serviceRequest.id}`, { correlationId }); + const requestBody = { + status: -1, + actioned_reason: 'User deactivation', + actioned_by: req.user.sub, + actioned_at: new Date(), + }; + updateUserServiceRequest(serviceRequest.id, requestBody, req.id); + } + } +} + +const rejectOpenOrganisationRequestsForUser = async (userId, req) => { + const correlationId = req.id; + const organisationRequests = await getPendingRequestsAssociatedWithUser(userId) || []; + for (const organisationRequest of organisationRequests) { + // Request status 0 is 'pending', 2 is 'overdue' and 3 is 'no approvers' + if (organisationRequest.status.id === 0 || organisationRequest.status.id === 2 || organisationRequest.status.id === 3) { + logger.info(`Rejecting organisation request with id: ${organisationRequest.id}`, { correlationId }); + const status = -1; + const actionedReason = 'User deactivation'; + const actionedBy = req.user.sub; + const actionedAt = new Date(); + updateRequestById(organisationRequest.id, status, actionedBy, actionedReason, actionedAt, req.id); + } + } +} + +const removeAllServicesForUser = async (userId, req) => { + const correlationId = req.id; + const userServices = await getServicesByUserId(userId) || []; + for (const service of userServices) { + logger.info(`Removing service from user: ${service.userId} with serviceId: ${service.serviceId} and organisationId: ${service.organisationId}`, { correlationId }); + removeServiceFromUser(service.userId, service.serviceId, service.organisationId, req.id); + } +} + +const removeAllServicesForInvitedUser = async (userId, req) => { + const correlationId = req.id; + logger.info(`Attemping to remove services from invite with id: ${userId}`, { correlationId }); + const invitationServiceRecords = await getServicesByInvitationId(userId.substr(4)) || []; + for (const serviceRecord of invitationServiceRecords) { + logger.info(`Deleting invitation service record for invitationId: ${serviceRecord.invitationId}, serviceId: ${serviceRecord.serviceId} and organisationId: ${serviceRecord.organisationIdId}`, { correlationId }); + removeServiceFromInvitation(serviceRecord.invitationId, serviceRecord.serviceId, serviceRecord.organisationId, correlationId); + } +} + module.exports = { search, + searchForBulkUsersPage, getUserDetails, getUserDetailsById, updateUserDetails, waitForIndexToUpdate, getAllServicesForUserInOrg, - mapRole + mapRole, + rejectOpenUserServiceRequestsForUser, + rejectOpenOrganisationRequestsForUser, + removeAllServicesForUser, + removeAllServicesForInvitedUser, }; diff --git a/src/app/users/views/bulkUserActions.ejs b/src/app/users/views/bulkUserActions.ejs new file mode 100644 index 00000000..788c1fc1 --- /dev/null +++ b/src/app/users/views/bulkUserActions.ejs @@ -0,0 +1,35 @@ +Back +
+
+

+ Bulk user actions +

+

Add all the emails you wish to work on in the text box, each separted by a comma

+
+
+
+
+
+ +
+ Emails +
+ <% if (locals.validationMessages.emails !== undefined) { %> +

<%= locals.validationMessages.emails %>

+ <% } %> + +
+
+ +
+ +
+ +
+
+
+ diff --git a/src/app/users/views/bulkUserActionsEmails.ejs b/src/app/users/views/bulkUserActionsEmails.ejs new file mode 100644 index 00000000..5fc0fdc9 --- /dev/null +++ b/src/app/users/views/bulkUserActionsEmails.ejs @@ -0,0 +1,71 @@ +Back +
+
+

+ Bulk user actions +

+

Select all the users you want to affect

+
+
+
+
+
+ + +
+ + + <% + let baseSortUri = ``; + %> + + + + + + + + + + + <% if(locals.users.length === 0) { %> + + + + <% } %> + <% for (let i = 0; i < locals.users.length; i++) { %> + + + + + + + + + <% } %> + +
NameEmailOrganisationLast LoginStatusSelected
No users found
<%= users[i].name %><%= users[i].email %> + <% if(users[i].organisation) { %> + <%= users[i].organisation.name %> + <% }else { %> + Unknown + <% } %> + + <% if(locals.users[i].lastLogin) { %> + <%= locals.moment(locals.users[i].lastLogin).fromNow() %> + <% } else { %> + Never + <% } %> + <%= users[i].status.description %>
+
+ +
+ +
+ +
+ +
+
+
+ diff --git a/src/app/users/views/search.ejs b/src/app/users/views/search.ejs index 5c44cf12..271c5809 100644 --- a/src/app/users/views/search.ejs +++ b/src/app/users/views/search.ejs @@ -105,6 +105,7 @@ if (services && services.length > 0) {

Actions