Skip to content

Commit

Permalink
PP-13133 simplified account settings - edit service name
Browse files Browse the repository at this point in the history
- added middlewares and router supporting simplified account routes
 - simplified account opt in: directs the user to an appropriate error message if a simplified account route is accessed without the user being opted in and the feature being enabled for the environment
 - simplified account strategy: retrieves the correct gateway account for the service and account type and makes this available on the request object
- added service settings navigation nunjucks template and supporting sass
- added settings controllers
 - index: redirects the user to the correct default setting based on the account type and go live stage of the service
 - service name: get and post actions for viewing and updating the current service name in english and welsh
 - email notifications: bare bones implementation of the view
- external dependency: express-validations, creates easy to understand validation chains used for parsing request bodies
- added utilities
 - nunjucks filter for correctly capitalising service settings and categories
 - generator for simplified account paths
 - settings builder: determines which settings should be visible based on the service go live stage, account type and permissions of the viewing user
  • Loading branch information
nlsteers committed Oct 10, 2024
1 parent deec0e7 commit a19b5f4
Show file tree
Hide file tree
Showing 29 changed files with 682 additions and 15 deletions.
1 change: 1 addition & 0 deletions app/assets/sass/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ $govuk-page-width: 1200px;
@import "components/additional-information";
@import "components/messages";
@import "components/spinner";
@import "components/service-settings";
35 changes: 35 additions & 0 deletions app/assets/sass/components/service-settings.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// https://getbem.com/naming/

.service-settings-nav {
@include govuk-font(16);
}

.service-settings-nav__h3 {
@include govuk-font(19);
@include govuk-typography-weight-regular();
margin: 0;
padding: govuk-spacing(2) govuk-spacing(3) govuk-spacing(2) 0;
color: govuk-colour("dark-grey");
}

.service-settings-nav__li {
@include govuk-font(16);
position: relative;
margin-bottom: govuk-spacing(1);
padding-top: govuk-spacing(1);
padding-bottom: govuk-spacing(1);
}

.service-settings-nav__li--active:before {
content: '';
position: absolute;
left: -14px;
top: 0;
height: 100%;
width: 4px;
background-color: $govuk-brand-colour;
}

.service-settings-nav__li--active {
@include govuk-typography-weight-bold();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const { response } = require('../../../../utils/response')

function get (req, res) {
return response(req, res, 'simplified-account/settings/email-notifications/index')
}

module.exports = {
get
}
22 changes: 22 additions & 0 deletions app/controllers/simplified-account/settings/index.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict'

const formatSimplifiedAccountPathsFor = require('../../../utils/simplified-account/format/format-simplified-account-paths-for')
const paths = require('../../../paths')
const { LIVE } = require('../../../models/go-live-stage')

function get (req, res) {
const account = req.account
const service = req.service
// the default setting for the index view is dependent on the account type and go live state
if (account.type === 'test' && service.currentGoLiveStage !== LIVE) {
return res.redirect(formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.index, req.account.service_id, req.account.type))
} else if (account.type === 'live') {
return res.redirect(formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.index, req.account.service_id, req.account.type))
} else {
return res.redirect(formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.emailNotifications.index, req.account.service_id, req.account.type))
}
}

module.exports = {
get
}
Empty file.
5 changes: 5 additions & 0 deletions app/controllers/simplified-account/settings/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict'

module.exports.index = require('./index.controller')
module.exports.serviceName = require('./service-name/service-name.controller')
module.exports.emailNotifications = require('./email-notifications/email-notifications.controller')
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use strict'
const { body, validationResult } = require('express-validator')
const { SERVICE_NAME_MAX_LENGTH } = require('../../../../utils/validation/server-side-form-validations')
const { response } = require('../../../../utils/response')
const { updateServiceName } = require('../../../../services/service.service')
const paths = require('../../../../paths')
const formatSimplifiedAccountPathsFor = require('../../../../utils/simplified-account/format/format-simplified-account-paths-for')
const formatValidationErrors = require('../../../../utils/simplified-account/format/format-validation-errors')

function get (req, res) {
const context = {
service_name_en: req.service.serviceName.en,
service_name_cy: req.service.serviceName.cy,
manage_en: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.edit, req.account.service_id, req.account.type),
manage_cy: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.edit, req.account.service_id, req.account.type) + '?cy=true'
}
return response(req, res, 'simplified-account/settings/service-name/index', context)
}

function getEditServiceName (req, res) {
const editCy = req.query.cy === 'true'
const context = {
edit_cy: editCy,
back_link: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.index, req.account.service_id, req.account.type),
submit_link: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.edit, req.account.service_id, req.account.type)
}
if (editCy) {
Object.assign(context, { service_name: req.service.serviceName.cy })
} else {
Object.assign(context, { service_name: req.service.serviceName.en })
}
return response(req, res, 'simplified-account/settings/service-name/edit-service-name', context)
}

async function postEditServiceName (req, res) {
const editCy = req.body.cy === 'true'
const validations = [
body('service-name-input').trim().isLength({ max: SERVICE_NAME_MAX_LENGTH }).withMessage(`Service name must be ${SERVICE_NAME_MAX_LENGTH} characters or fewer`)
]
// we don't check presence for welsh names
if (!editCy) {
validations.push(body('service-name-input').trim().notEmpty().withMessage('Service name is required'))
}

await Promise.all(validations.map(validation => validation.run(req)))
const errors = validationResult(req)
if (!errors.isEmpty()) {
const formattedErrors = formatValidationErrors(errors)
return response(req, res, 'simplified-account/settings/service-name/edit-service-name', {
errors: {
summary: formattedErrors.errorSummary,
formErrors: formattedErrors.formErrors
},
edit_cy: editCy,
service_name: req.body['service-name-input'],
back_link: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.index, req.account.service_id, req.account.type),
submit_link: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.edit, req.account.service_id, req.account.type)
})
}

const newServiceName = req.body['service-name-input']
editCy ? await updateServiceName(req.account.service_id, req.service.serviceName.en, newServiceName) : await updateServiceName(req.account.service_id, newServiceName, req.service.serviceName.cy)
res.redirect(formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.index, req.account.service_id, req.account.type))
}

module.exports = {
get,
getEditServiceName,
postEditServiceName
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const { InvalidConfigurationError } = require('../../errors')

module.exports = function checkDegatewayParameters (req, res, next) {
const user = req.user
if (user.isDegatewayed()) {
return next()
}
return next(new InvalidConfigurationError(`User with id ${user.externalId} not opted in to account simplification or feature is disabled in this environment.`))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict'
// EXTERNAL IMPORTS
const _ = require('lodash')
const { keys } = require('@govuk-pay/pay-js-commons').logging
// LOCAL IMPORTS
const { addField } = require('../../services/clients/base/request-context')
const Connector = require('../../services/clients/connector.client.js').ConnectorClient
const { getSwitchingCredentialIfExists } = require('../../utils/credentials')
const { SERVICE_EXTERNAL_ID, ACCOUNT_TYPE, ENVIRONMENT_ID } = require('../../paths').keys
const logger = require('../../utils/logger')(__filename)
const connectorClient = new Connector(process.env.CONNECTOR_URL)

function getService (user, serviceExternalId, gatewayAccountId) {
let service
const serviceRoles = _.get(user, 'serviceRoles', [])

if (serviceRoles.length > 0) {
if (serviceExternalId) {
service = _.get(serviceRoles.find(serviceRole => {
return (serviceRole.service.externalId === serviceExternalId &&
(!gatewayAccountId || serviceRole.service.gatewayAccountIds.includes(String(gatewayAccountId))))
}), 'service')
}
}

return service
}

async function getGatewayAccountByServiceIdAndAccountType (serviceExternalId, accountType) {
try {
const params = {
serviceId: serviceExternalId,
accountType
}
let account = await connectorClient.getAccountByServiceIdAndAccountType(params)

account = _.extend({}, account, {
supports3ds: ['worldpay', 'stripe', 'epdq', 'smartpay'].includes(account.payment_provider),
disableToggle3ds: account.payment_provider === 'stripe'
})

const switchingCredential = getSwitchingCredentialIfExists(account)
const isSwitchingToStripe = switchingCredential && switchingCredential.payment_provider === 'stripe'
if (account.payment_provider === 'stripe' || isSwitchingToStripe) {
const stripeAccountSetup = await connectorClient.getStripeAccountSetup(account.gateway_account_id)
if (stripeAccountSetup) {
account.connectorGatewayAccountStripeProgress = stripeAccountSetup
}
}

return account
} catch (err) {
const logContext = {
error: err.message,
error_code: err.errorCode
}

if (err.errorCode === 404) {
logger.info('Gateway account not found', logContext)
} else {
logger.error('Error retrieving gateway account', logContext)
}
}
}

module.exports = async function getSimplifiedAccount (req, res, next) {
try {
if (req.user) {
const serviceExternalId = req.params[SERVICE_EXTERNAL_ID]
const accountType = req.params[ACCOUNT_TYPE]
const environment = req.params[ENVIRONMENT_ID]

if (!serviceExternalId || !accountType) {
throw new Error('Could not resolve service external ID or gateway account type from request params')
}

let gatewayAccount = await getGatewayAccountByServiceIdAndAccountType(serviceExternalId, accountType)
if (gatewayAccount) {
req.account = gatewayAccount
addField(keys.GATEWAY_ACCOUNT_ID, gatewayAccount.gateway_account_id)
addField(keys.GATEWAY_ACCOUNT_TYPE, gatewayAccount.type)
req.gateway_account = {
currentGatewayAccountExternalId: gatewayAccount.external_id
}
}
const service = getService(req.user, serviceExternalId, gatewayAccount.gateway_account_id)
if (service) {
req.service = service
addField(keys.SERVICE_EXTERNAL_ID, service.externalId)
}

if (environment) {
req.isLive = environment === 'live'
}
}

next()
} catch (err) {
next(err)
}
}
34 changes: 33 additions & 1 deletion app/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const formattedPathFor = require('./utils/replace-params-in-path')
const keys = {
ENVIRONMENT_ID: 'environmentId',
SERVICE_EXTERNAL_ID: 'serviceExternalId',
GATEWAY_ACCOUNT_EXTERNAL_ID: 'gatewayAccountExternalId'
GATEWAY_ACCOUNT_EXTERNAL_ID: 'gatewayAccountExternalId',
ACCOUNT_TYPE: 'accountType'
}

module.exports = {
Expand Down Expand Up @@ -166,6 +167,37 @@ module.exports = {
cancel: '/agreements/:agreementId/cancel'
}
},
simplifiedAccount: {
root: `/simplified/service/:${keys.SERVICE_EXTERNAL_ID}/account/:${keys.ACCOUNT_TYPE}`,
settings: {
index: '/settings',
serviceName: {
index: '/settings/service-name',
edit: '/settings/service-name/edit'
},
emailNotifications: {
index: '/settings/email-notifications'
},
teamMembers: {
index: '/settings/team-members'
},
orgDetails: {
index: '/settings/organisation-details'
},
cardPayments: {
index: '/settings/card-payments'
},
cardTypes: {
index: '/settings/card-types'
},
apiKeys: {
index: '/settings/api-keys'
},
webhooks: {
index: '/settings/webhooks'
}
}
},
service: {
root: `/service/:${keys.SERVICE_EXTERNAL_ID}`,
editServiceName: {
Expand Down
21 changes: 21 additions & 0 deletions app/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const accountUrls = require('./utils/gateway-account-urls')

const userIsAuthorised = require('./middleware/user-is-authorised')
const getServiceAndAccount = require('./middleware/get-service-and-gateway-account.middleware')
const getSimplifiedAccount = require('./middleware/simplified-account/simplified-account-strategy.middleware')
const isOptedInToSimplifiedAccounts = require('./middleware/simplified-account/simplified-account-opt-in.middleware')
const { NotFoundError } = require('./errors')

// Middleware
Expand Down Expand Up @@ -91,6 +93,9 @@ const organisationUrlController = require('./controllers/switch-psp/organisation
const registrationController = require('./controllers/registration/registration.controller')
const privacyController = require('./controllers/privacy/privacy.controller')

// Simplified Accounts controllers
const serviceSettingsController = require('./controllers/simplified-account/settings')

// Assignments
const {
allServiceTransactions,
Expand Down Expand Up @@ -151,6 +156,9 @@ module.exports.bind = function (app) {
const service = new Router({ mergeParams: true })
service.use(getServiceAndAccount, userIsAuthorised)

const simplifiedAccount = new Router({ mergeParams: true })
simplifiedAccount.use(isOptedInToSimplifiedAccounts, getSimplifiedAccount, userIsAuthorised)

app.get('/style-guide', (req, res) => response(req, res, 'style_guide'))

// ----------------------
Expand Down Expand Up @@ -489,9 +497,22 @@ module.exports.bind = function (app) {
futureAccountStrategy.get(webhooks.toggleActive, permission('webhooks:update'), webhooksController.toggleActivePage)
futureAccountStrategy.post(webhooks.toggleActive, permission('webhooks:update'), webhooksController.toggleActiveWebhook)

// -------------------------------------------------------------------------------
// ROUTES BY SERVICE ID AND ACCOUNT TYPE - ACCOUNT SIMPLIFICATION
// -------------------------------------------------------------------------------

simplifiedAccount.get(paths.simplifiedAccount.settings.index, serviceSettingsController.index.get)
// service name
simplifiedAccount.get(paths.simplifiedAccount.settings.serviceName.index, permission('service-name:update'), serviceSettingsController.serviceName.get)
simplifiedAccount.get(paths.simplifiedAccount.settings.serviceName.edit, permission('service-name:update'), serviceSettingsController.serviceName.getEditServiceName)
simplifiedAccount.post(paths.simplifiedAccount.settings.serviceName.edit, permission('service-name:update'), serviceSettingsController.serviceName.postEditServiceName)
// email notifications
simplifiedAccount.get(paths.simplifiedAccount.settings.emailNotifications.index, permission('service-name:update'), serviceSettingsController.emailNotifications.get)

app.use(paths.account.root, account)
app.use(paths.service.root, service)
app.use(paths.futureAccountStrategy.root, futureAccountStrategy)
app.use(paths.simplifiedAccount.root, simplifiedAccount)

// security.txt — https://gds-way.cloudapps.digital/standards/vulnerability-disclosure.html
const securitytxt = 'https://vdp.cabinetoffice.gov.uk/.well-known/security.txt'
Expand Down
6 changes: 6 additions & 0 deletions app/utils/custom-nunjucks-filters/smart-caps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict'

module.exports = str => {
if (str === undefined) return
return str.charAt(0).toUpperCase() + str.slice(1)
}
7 changes: 6 additions & 1 deletion app/utils/display-converter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const url = require('url')
const getHeldPermissions = require('./get-held-permissions')
const { serviceNavigationItems, adminNavigationItems } = require('./nav-builder')
const formatPSPname = require('./format-PSP-name')
const serviceSettings = require('./simplified-account/settings-builder')

const hideServiceHeaderTemplates = [
'services/add-service',
Expand Down Expand Up @@ -138,9 +139,13 @@ module.exports = function (req, data, template) {
convertedData.isLive = req.isLive
convertedData.humanReadableEnvironment = convertedData.isLive ? 'Live' : 'Test'
const currentPath = (relativeUrl && url.parse(relativeUrl).pathname.replace(/([a-z])\/$/g, '$1')) || '' // remove query params and trailing slash
const currentUrl = req.baseUrl + req.path
if (permissions) {
convertedData.serviceNavigationItems = serviceNavigationItems(currentPath, permissions, paymentMethod, account)
convertedData.serviceNavigationItems = serviceNavigationItems(currentPath, permissions, paymentMethod, account, isDegatewayed, currentUrl)
convertedData.adminNavigationItems = adminNavigationItems(currentPath, permissions, paymentMethod, paymentProvider, account, service)
if (currentUrl.includes('simplified') && currentUrl.includes('settings')) {
convertedData.serviceSettings = serviceSettings(account, currentUrl, service.currentGoLiveStage, permissions)
}
}
convertedData._features = {}
if (req.user && req.user.features) {
Expand Down
Loading

0 comments on commit a19b5f4

Please sign in to comment.