diff --git a/README.md b/README.md
index f24a54c0..4052ffda 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ API_CLIENT_SECRET=clientsecret
SYSTEM_CLIENT_ID=clientid
SYSTEM_CLIENT_SECRET=clientsecret
+COMPONENT_API_URL="https://frontend-components-dev.hmpps.service.justice.gov.uk"
NOMIS_USER_ROLES_API_URL="https://nomis-user-roles-api-dev.prison.service.justice.gov.uk"
ORCHESTRATION_API_URL="https://hmpps-manage-prison-visits-orchestration-dev.prison.service.justice.gov.uk"
PRISONER_SEARCH_API_URL="https://prisoner-search-dev.prison.service.justice.gov.uk"
diff --git a/assets/sass/application.scss b/assets/sass/application.scss
index c2fde00c..ab4df452 100755
--- a/assets/sass/application.scss
+++ b/assets/sass/application.scss
@@ -12,6 +12,7 @@ $govuk-page-width: $moj-page-width;
@import './components/block-background';
@import './components/date-picker';
@import './components/filter';
+@import './components/footer';
@import './components/notificationTypes';
@import './components/prisoner-profile';
@import './components/restrictions';
diff --git a/assets/sass/components/_footer.scss b/assets/sass/components/_footer.scss
new file mode 100644
index 00000000..f83d3ec9
--- /dev/null
+++ b/assets/sass/components/_footer.scss
@@ -0,0 +1,3 @@
+.govuk-footer {
+ min-height: 160px;
+}
diff --git a/cypress.config.ts b/cypress.config.ts
index 38e4774d..b4f17530 100644
--- a/cypress.config.ts
+++ b/cypress.config.ts
@@ -2,6 +2,7 @@ import { defineConfig } from 'cypress'
import { resetStubs } from './integration_tests/mockApis/wiremock'
import auth from './integration_tests/mockApis/auth'
+import frontendComponents from './integration_tests/mockApis/frontendComponents'
import manageUsersApi from './integration_tests/mockApis/manageUsersApi'
import tokenVerification from './integration_tests/mockApis/tokenVerification'
import nomisUserRoles from './integration_tests/mockApis/nomisUserRoles'
@@ -32,6 +33,7 @@ export default defineConfig({
...manageUsersApi,
...tokenVerification,
+ ...frontendComponents,
...nomisUserRoles,
...orchestrationService,
...prisonerContactRegistry,
diff --git a/feature.env b/feature.env
index 46ca7cb1..48e90d75 100644
--- a/feature.env
+++ b/feature.env
@@ -13,6 +13,7 @@ SYSTEM_CLIENT_ID=clientid
SYSTEM_CLIENT_SECRET=clientsecret
FEATURE_REVIEW_BOOKINGS=true
+COMPONENT_API_URL=http://localhost:9091/frontendComponents
NOMIS_USER_ROLES_API_URL=http://localhost:9091/nomisUserRoles
ORCHESTRATION_API_URL=http://localhost:9091/orchestration
PRISONER_SEARCH_API_URL=http://localhost:9091/offenderSearch
diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml
index cf32d473..d2c2dfec 100644
--- a/helm_deploy/values-dev.yaml
+++ b/helm_deploy/values-dev.yaml
@@ -19,6 +19,7 @@ generic-service:
PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry-dev.prison.service.justice.gov.uk"
WHEREABOUTS_API_URL: "https://whereabouts-api-dev.service.justice.gov.uk"
PRISON_REGISTER_API_URL: "https://prison-register-dev.hmpps.service.justice.gov.uk"
+ COMPONENT_API_URL: https://frontend-components-dev.hmpps.service.justice.gov.uk
DPS_URL: "https://digital-dev.prison.service.justice.gov.uk/"
REDIS_KEY: "bapv-staff-dev"
FEATURE_REVIEW_BOOKINGS: "true"
diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml
index 20b44972..8f13c75b 100644
--- a/helm_deploy/values-preprod.yaml
+++ b/helm_deploy/values-preprod.yaml
@@ -20,6 +20,7 @@ generic-service:
PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry-preprod.prison.service.justice.gov.uk"
WHEREABOUTS_API_URL: "https://whereabouts-api-preprod.service.justice.gov.uk"
PRISON_REGISTER_API_URL: "https://prison-register-preprod.hmpps.service.justice.gov.uk"
+ COMPONENT_API_URL: https://frontend-components-preprod.hmpps.service.justice.gov.uk
DPS_URL: "https://digital-preprod.prison.service.justice.gov.uk/"
REDIS_KEY: "bapv-staff-preprod"
FEATURE_REVIEW_BOOKINGS: "true"
diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml
index 2e2ad494..89070c56 100644
--- a/helm_deploy/values-prod.yaml
+++ b/helm_deploy/values-prod.yaml
@@ -20,6 +20,7 @@ generic-service:
PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry.prison.service.justice.gov.uk"
WHEREABOUTS_API_URL: "https://whereabouts-api.service.justice.gov.uk"
PRISON_REGISTER_API_URL: "https://prison-register.hmpps.service.justice.gov.uk"
+ COMPONENT_API_URL: https://frontend-components.hmpps.service.justice.gov.uk
DPS_URL: "https://digital.prison.service.justice.gov.uk/"
REDIS_KEY: "bapv-staff-prod"
FEATURE_REVIEW_BOOKINGS: "true"
diff --git a/helm_deploy/values-staging.yaml b/helm_deploy/values-staging.yaml
index 384304bd..e1746e00 100644
--- a/helm_deploy/values-staging.yaml
+++ b/helm_deploy/values-staging.yaml
@@ -19,6 +19,7 @@ generic-service:
PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry-staging.prison.service.justice.gov.uk"
WHEREABOUTS_API_URL: "https://whereabouts-api-dev.service.justice.gov.uk"
PRISON_REGISTER_API_URL: "https://prison-register-dev.hmpps.service.justice.gov.uk"
+ COMPONENT_API_URL: https://frontend-components-dev.hmpps.service.justice.gov.uk
DPS_URL: "https://digital-dev.prison.service.justice.gov.uk/"
APPLICATIONINSIGHTS_CONNECTION_STRING: null # disable App Insights for staging
REDIS_KEY: "bapv-staff-staging"
diff --git a/integration_tests/integration/bookAVisit.cy.ts b/integration_tests/integration/bookAVisit.cy.ts
index 17c69ecf..81578dd1 100644
--- a/integration_tests/integration/bookAVisit.cy.ts
+++ b/integration_tests/integration/bookAVisit.cy.ts
@@ -36,6 +36,7 @@ context('Book a visit', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/cancelVisit.cy.ts b/integration_tests/integration/cancelVisit.cy.ts
index 163f785a..91ba36df 100644
--- a/integration_tests/integration/cancelVisit.cy.ts
+++ b/integration_tests/integration/cancelVisit.cy.ts
@@ -28,6 +28,7 @@ context('Cancel visit journey', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/changeEstablishment.cy.ts b/integration_tests/integration/changeEstablishment.cy.ts
index f973c809..486971c7 100644
--- a/integration_tests/integration/changeEstablishment.cy.ts
+++ b/integration_tests/integration/changeEstablishment.cy.ts
@@ -7,6 +7,7 @@ context('Change establishment', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/checkYourBooking.cy.ts b/integration_tests/integration/checkYourBooking.cy.ts
index d9e86bad..9938e397 100644
--- a/integration_tests/integration/checkYourBooking.cy.ts
+++ b/integration_tests/integration/checkYourBooking.cy.ts
@@ -18,6 +18,7 @@ context('Check visit details page', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/datePicker.cy.ts b/integration_tests/integration/datePicker.cy.ts
index f9d81684..6f569ca9 100644
--- a/integration_tests/integration/datePicker.cy.ts
+++ b/integration_tests/integration/datePicker.cy.ts
@@ -12,6 +12,7 @@ context('Date picker', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/frontendComponents.cy.ts b/integration_tests/integration/frontendComponents.cy.ts
new file mode 100644
index 00000000..096cda84
--- /dev/null
+++ b/integration_tests/integration/frontendComponents.cy.ts
@@ -0,0 +1,33 @@
+import TestData from '../../server/routes/testutils/testData'
+import HomePage from '../pages/home'
+import Page from '../pages/page'
+
+context('Frontend components', () => {
+ const notificationCount = TestData.notificationCount()
+
+ beforeEach(() => {
+ cy.task('reset')
+ cy.task('stubSignIn')
+ cy.task('stubManageUser')
+ cy.task('stubSupportedPrisonIds')
+ cy.task('stubPrisonNames')
+ cy.task('stubGetPrison')
+ cy.task('stubGetNotificationCount', { notificationCount })
+ })
+
+ it('should render the footer from the frontend components', () => {
+ cy.task('stubFrontendComponents')
+ cy.signIn()
+
+ Page.verifyOnPage(HomePage)
+ cy.get('footer').contains('Footer component')
+ })
+
+ it('should render the fallback footer when frontend components load fails', () => {
+ cy.task('stubFrontendComponentsFail')
+ cy.signIn()
+
+ Page.verifyOnPage(HomePage)
+ cy.get('footer').should('not.contain.text', 'Footer component')
+ })
+})
diff --git a/integration_tests/integration/home.cy.ts b/integration_tests/integration/home.cy.ts
index 34998475..058d6f82 100644
--- a/integration_tests/integration/home.cy.ts
+++ b/integration_tests/integration/home.cy.ts
@@ -8,6 +8,7 @@ context('Home page', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/login.cy.ts b/integration_tests/integration/login.cy.ts
index 2bdc405a..05fcebc6 100644
--- a/integration_tests/integration/login.cy.ts
+++ b/integration_tests/integration/login.cy.ts
@@ -8,6 +8,7 @@ context('SignIn', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/prisonerProfile.cy.ts b/integration_tests/integration/prisonerProfile.cy.ts
index 923163a0..fca9f46e 100644
--- a/integration_tests/integration/prisonerProfile.cy.ts
+++ b/integration_tests/integration/prisonerProfile.cy.ts
@@ -9,6 +9,7 @@ context('Prisoner profile page', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/review.cy.ts b/integration_tests/integration/review.cy.ts
index e34b6221..9747f085 100644
--- a/integration_tests/integration/review.cy.ts
+++ b/integration_tests/integration/review.cy.ts
@@ -45,6 +45,7 @@ context('Bookings review page', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/searchForABooking.cy.ts b/integration_tests/integration/searchForABooking.cy.ts
index caf79963..f2a46219 100644
--- a/integration_tests/integration/searchForABooking.cy.ts
+++ b/integration_tests/integration/searchForABooking.cy.ts
@@ -20,6 +20,7 @@ context('Search for a booking by reference', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/searchForAPrisoner.cy.ts b/integration_tests/integration/searchForAPrisoner.cy.ts
index 0b962772..7e744c63 100644
--- a/integration_tests/integration/searchForAPrisoner.cy.ts
+++ b/integration_tests/integration/searchForAPrisoner.cy.ts
@@ -11,6 +11,7 @@ context('Search for a prisoner', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/updateAVisit.cy.ts b/integration_tests/integration/updateAVisit.cy.ts
index 273c2312..ed4208bf 100644
--- a/integration_tests/integration/updateAVisit.cy.ts
+++ b/integration_tests/integration/updateAVisit.cy.ts
@@ -24,6 +24,7 @@ context('Update a visit', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/visitDetails.cy.ts b/integration_tests/integration/visitDetails.cy.ts
index f87cf3a1..abe749d5 100644
--- a/integration_tests/integration/visitDetails.cy.ts
+++ b/integration_tests/integration/visitDetails.cy.ts
@@ -13,6 +13,7 @@ context('Visit details page', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/visitTimetable.cy.ts b/integration_tests/integration/visitTimetable.cy.ts
index b8f16b93..8eb52694 100644
--- a/integration_tests/integration/visitTimetable.cy.ts
+++ b/integration_tests/integration/visitTimetable.cy.ts
@@ -15,6 +15,7 @@ context('View visit schedule timetable', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/integration/visitsByDate.cy.ts b/integration_tests/integration/visitsByDate.cy.ts
index 6a1aff46..44aba0b9 100644
--- a/integration_tests/integration/visitsByDate.cy.ts
+++ b/integration_tests/integration/visitsByDate.cy.ts
@@ -34,6 +34,7 @@ context('View visits by date', () => {
beforeEach(() => {
cy.task('reset')
cy.task('stubSignIn')
+ cy.task('stubFrontendComponents')
cy.task('stubManageUser')
cy.task('stubSupportedPrisonIds')
cy.task('stubPrisonNames')
diff --git a/integration_tests/mockApis/frontendComponents.ts b/integration_tests/mockApis/frontendComponents.ts
new file mode 100644
index 00000000..7ce9f5f3
--- /dev/null
+++ b/integration_tests/mockApis/frontendComponents.ts
@@ -0,0 +1,36 @@
+import { SuperAgentRequest } from 'superagent'
+import { stubFor } from './wiremock'
+
+export default {
+ stubFrontendComponents: (): SuperAgentRequest => {
+ return stubFor({
+ request: {
+ method: 'GET',
+ url: '/frontendComponents/components?component=footer',
+ },
+ response: {
+ status: 200,
+ headers: { 'Content-Type': 'application/json;charset=UTF-8' },
+ jsonBody: {
+ footer: {
+ html: '',
+ css: [],
+ javascript: [],
+ },
+ },
+ },
+ })
+ },
+
+ stubFrontendComponentsFail: () => {
+ return stubFor({
+ request: {
+ method: 'GET',
+ url: '/frontendComponents/components?component=footer',
+ },
+ response: {
+ status: 500,
+ },
+ })
+ },
+}
diff --git a/server/app.ts b/server/app.ts
index ea20ca6f..b834545c 100755
--- a/server/app.ts
+++ b/server/app.ts
@@ -26,6 +26,7 @@ import timetableRoutes from './routes/timetable'
import visitRoutes from './routes/visit'
import visitsRoutes from './routes/visits'
import type { Services } from './services'
+import getFrontendComponents from './middleware/setupFrontendComponents'
export default function createApp(services: Services): express.Application {
const app = express()
@@ -46,6 +47,8 @@ export default function createApp(services: Services): express.Application {
app.use(setUpCurrentUser(services))
app.use(appInsightsOperationId)
+ app.get('*', getFrontendComponents(services))
+
app.use('/', indexRoutes(services))
app.use('/book-a-visit', bookAVisitRoutes(services))
app.use('/change-establishment', establishmentRoutes(services))
diff --git a/server/config.ts b/server/config.ts
index 9f90203f..0ae9324e 100755
--- a/server/config.ts
+++ b/server/config.ts
@@ -145,6 +145,14 @@ export default {
},
agent: new AgentConfig(Number(get('ORCHESTRATION_API_TIMEOUT_RESPONSE', 10000))),
},
+ frontendComponents: {
+ url: get('COMPONENT_API_URL', 'http://localhost:8082/frontend-components', requiredInProduction),
+ timeout: {
+ response: Number(get('FRONTEND_COMPONENTS_TIMEOUT_RESPONSE', 3000)),
+ deadline: Number(get('FRONTEND_COMPONENTS_TIMEOUT_DEADLINE', 3000)),
+ },
+ agent: new AgentConfig(Number(get('FRONTEND_COMPONENTS_TIMEOUT_RESPONSE', 3000))),
+ },
},
features: {
reviewBookings: get('FEATURE_REVIEW_BOOKINGS', 'false', requiredInProduction) === 'true',
diff --git a/server/data/frontendComponentsClient.test.ts b/server/data/frontendComponentsClient.test.ts
new file mode 100644
index 00000000..93752df1
--- /dev/null
+++ b/server/data/frontendComponentsClient.test.ts
@@ -0,0 +1,36 @@
+import nock from 'nock'
+import config from '../config'
+import FrontendComponentsClient from './frontendComponentsClient'
+
+describe('FrontendComponentsClient', () => {
+ const frontendComponentsClient = new FrontendComponentsClient('not used')
+ let fakeFrontendComponentsApi: nock.Scope
+
+ beforeEach(() => {
+ fakeFrontendComponentsApi = nock(config.apis.frontendComponents.url)
+ })
+
+ afterEach(() => {
+ if (!nock.isDone()) {
+ nock.cleanAll()
+ throw new Error('Not all nock interceptors were used!')
+ }
+ nock.abortPendingRequests()
+ nock.cleanAll()
+ })
+
+ describe('getComponents', () => {
+ it('should get frontend components', async () => {
+ const userToken = 'user1'
+ const response = { some: 'response' }
+
+ fakeFrontendComponentsApi
+ .get('/components?component=header&component=footer')
+ .matchHeader('x-user-token', userToken)
+ .reply(200, response)
+
+ const result = await frontendComponentsClient.getComponents(['header', 'footer'], userToken)
+ expect(result).toStrictEqual(response)
+ })
+ })
+})
diff --git a/server/data/frontendComponentsClient.ts b/server/data/frontendComponentsClient.ts
new file mode 100644
index 00000000..13c621b2
--- /dev/null
+++ b/server/data/frontendComponentsClient.ts
@@ -0,0 +1,29 @@
+import RestClient from './restClient'
+import config from '../config'
+
+export interface Component {
+ html: string
+ css: string[]
+ javascript: string[]
+}
+
+export type AvailableComponent = 'header' | 'footer'
+
+export default class FrontendComponentsClient {
+ restClient: RestClient
+
+ constructor(token: string) {
+ this.restClient = new RestClient('Frontend components', config.apis.frontendComponents, token)
+ }
+
+ async getComponents(
+ components: T,
+ userToken: string,
+ ): Promise> {
+ return this.restClient.get({
+ path: `/components`,
+ query: `component=${components.join('&component=')}`,
+ headers: { 'x-user-token': userToken },
+ })
+ }
+}
diff --git a/server/data/index.ts b/server/data/index.ts
index addf7383..c001f520 100644
--- a/server/data/index.ts
+++ b/server/data/index.ts
@@ -21,6 +21,7 @@ import PrisonRegisterApiClient from './prisonRegisterApiClient'
import { createRedisClient } from './redisClient'
import TokenStore from './tokenStore'
import WhereaboutsApiClient from './whereaboutsApiClient'
+import FrontendComponentsClient from './frontendComponentsClient'
type RestClientBuilder = (token: string) => T
@@ -29,6 +30,8 @@ export const dataAccess = () => ({
hmppsAuthClient: new HmppsAuthClient(new TokenStore(createRedisClient())),
manageUsersApiClient: new ManageUsersApiClient(),
nomisUserRolesApiClient: new NomisUserRolesApiClient(),
+ frontendComponentsClientBuilder: ((token: string) =>
+ new FrontendComponentsClient(token)) as RestClientBuilder,
orchestrationApiClientBuilder: ((token: string) =>
new OrchestrationApiClient(token)) as RestClientBuilder,
prisonApiClientBuilder: ((token: string) => new PrisonApiClient(token)) as RestClientBuilder,
@@ -46,6 +49,7 @@ export type DataAccess = ReturnType
export {
HmppsAuthClient,
+ FrontendComponentsClient,
ManageUsersApiClient,
NomisUserRolesApiClient,
OrchestrationApiClient,
diff --git a/server/data/testutils/mocks.ts b/server/data/testutils/mocks.ts
index decb67c2..8a7e63b6 100644
--- a/server/data/testutils/mocks.ts
+++ b/server/data/testutils/mocks.ts
@@ -17,6 +17,7 @@ jest.mock('../../applicationInfo', () => {
})
import {
+ FrontendComponentsClient,
HmppsAuthClient,
ManageUsersApiClient,
NomisUserRolesApiClient,
@@ -29,6 +30,8 @@ import {
} from '..'
jest.mock('..')
+export const createMockFrontendComponentsClient = () =>
+ new FrontendComponentsClient(null) as jest.Mocked
export const createMockHmppsAuthClient = () => new HmppsAuthClient(null) as jest.Mocked
diff --git a/server/middleware/setUpWebSecurity.ts b/server/middleware/setUpWebSecurity.ts
index c79e1901..aa6c6098 100644
--- a/server/middleware/setUpWebSecurity.ts
+++ b/server/middleware/setUpWebSecurity.ts
@@ -24,10 +24,19 @@ export default function setUpWebSecurity(): Router {
//
// This ensures only scripts we trust are loaded, and not anything injected into the
// page by an attacker.
- scriptSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`],
- styleSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`],
- fontSrc: ["'self'"],
- formAction: [`'self' ${config.apis.hmppsAuth.externalUrl}`],
+ scriptSrc: [
+ "'self'",
+ (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`,
+ config.apis.frontendComponents.url,
+ ],
+ styleSrc: [
+ "'self'",
+ (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`,
+ config.apis.frontendComponents.url,
+ ],
+ fontSrc: ["'self'", config.apis.frontendComponents.url],
+ imgSrc: ["'self'", 'data:', config.apis.frontendComponents.url],
+ formAction: [`'self' ${config.apis.hmppsAuth.externalUrl} ${config.dpsHome}`],
upgradeInsecureRequests: process.env.NODE_ENV === 'development' ? null : [],
},
},
diff --git a/server/middleware/setupFrontendComponents.test.ts b/server/middleware/setupFrontendComponents.test.ts
new file mode 100644
index 00000000..284531f4
--- /dev/null
+++ b/server/middleware/setupFrontendComponents.test.ts
@@ -0,0 +1,62 @@
+import type { Request, Response } from 'express'
+import { createMockFrontendComponentsService } from '../services/testutils/mocks'
+import getFrontendComponents from './setupFrontendComponents'
+import { Services } from '../services'
+import logger from '../../logger'
+
+jest.mock('../../logger')
+
+const frontendComponentsService = createMockFrontendComponentsService()
+let req: Request
+let res: Response
+const next = jest.fn()
+
+describe('getFrontendComponents', () => {
+ beforeEach(() => {
+ res = {
+ locals: {
+ user: {
+ token: 'user-token',
+ },
+ },
+ } as Response
+ })
+
+ afterEach(() => {
+ jest.resetAllMocks()
+ })
+
+ it('should call frontend components service and populate res.locals', async () => {
+ const response = {
+ header: {
+ html: 'header html',
+ css: ['header css'],
+ javascript: ['header js'],
+ },
+ footer: {
+ html: 'footer html',
+ css: ['footer css'],
+ javascript: ['footer js'],
+ },
+ }
+ frontendComponentsService.getComponents.mockResolvedValue(response)
+
+ await getFrontendComponents({ frontendComponentsService } as unknown as Services)(req, res, next)
+
+ expect(frontendComponentsService.getComponents).toHaveBeenCalledWith(['footer'], 'user-token')
+ expect(res.locals.feComponents).toStrictEqual({
+ footer: response.footer.html,
+ cssIncludes: [...response.footer.css],
+ jsIncludes: [...response.footer.javascript],
+ })
+ })
+
+ it('should log errors', async () => {
+ const error = new Error('Oops')
+ frontendComponentsService.getComponents.mockRejectedValue(error)
+
+ await getFrontendComponents({ frontendComponentsService } as unknown as Services)(req, res, next)
+
+ expect(logger.error).toHaveBeenCalledWith(error, 'Failed to retrieve front end components')
+ })
+})
diff --git a/server/middleware/setupFrontendComponents.ts b/server/middleware/setupFrontendComponents.ts
new file mode 100644
index 00000000..0d429f37
--- /dev/null
+++ b/server/middleware/setupFrontendComponents.ts
@@ -0,0 +1,21 @@
+import { RequestHandler } from 'express'
+import { Services } from '../services'
+import logger from '../../logger'
+
+export default function getFrontendComponents({ frontendComponentsService }: Services): RequestHandler {
+ return async (req, res, next) => {
+ try {
+ const { footer } = await frontendComponentsService.getComponents(['footer'], res.locals.user.token)
+
+ res.locals.feComponents = {
+ footer: footer.html,
+ cssIncludes: [...footer.css],
+ jsIncludes: [...footer.javascript],
+ }
+ next()
+ } catch (error) {
+ logger.error(error, 'Failed to retrieve front end components')
+ next()
+ }
+ }
+}
diff --git a/server/services/frontendComponentsService.test.ts b/server/services/frontendComponentsService.test.ts
new file mode 100644
index 00000000..814ff872
--- /dev/null
+++ b/server/services/frontendComponentsService.test.ts
@@ -0,0 +1,44 @@
+import { AvailableComponent } from '../data/frontendComponentsClient'
+import { createMockFrontendComponentsClient } from '../data/testutils/mocks'
+import FrontendComponentsService from './frontendComponentsService'
+
+describe('FrontendComponentsService', () => {
+ const frontendComponentsClient = createMockFrontendComponentsClient()
+ let frontendComponentsService: FrontendComponentsService
+ const FrontendComponentsClientBuilder = jest.fn()
+
+ const components: AvailableComponent[] = ['header', 'footer']
+ const userToken = 'user1'
+
+ beforeEach(() => {
+ FrontendComponentsClientBuilder.mockReturnValue(frontendComponentsClient)
+ frontendComponentsService = new FrontendComponentsService(FrontendComponentsClientBuilder)
+ })
+
+ afterEach(() => {
+ jest.resetAllMocks()
+ })
+
+ describe('getComponents', () => {
+ it('should call API and return component data', async () => {
+ const response = {
+ header: {
+ html: 'header html',
+ css: ['header css'],
+ javascript: ['header js'],
+ },
+ footer: {
+ html: 'footer html',
+ css: ['footer css'],
+ javascript: ['footer js'],
+ },
+ }
+ frontendComponentsClient.getComponents.mockResolvedValue(response)
+
+ const result = await frontendComponentsService.getComponents(components, userToken)
+
+ expect(frontendComponentsClient.getComponents).toHaveBeenCalledWith(components, userToken)
+ expect(result).toStrictEqual(response)
+ })
+ })
+})
diff --git a/server/services/frontendComponentsService.ts b/server/services/frontendComponentsService.ts
new file mode 100644
index 00000000..491592f3
--- /dev/null
+++ b/server/services/frontendComponentsService.ts
@@ -0,0 +1,13 @@
+import { FrontendComponentsClient, RestClientBuilder } from '../data'
+import { AvailableComponent, Component } from '../data/frontendComponentsClient'
+
+export default class FrontendComponentsService {
+ constructor(private readonly frontendComponentsClientBuilder: RestClientBuilder) {}
+
+ async getComponents(
+ components: T,
+ userToken: string,
+ ): Promise> {
+ return this.frontendComponentsClientBuilder(userToken).getComponents(components, userToken)
+ }
+}
diff --git a/server/services/index.ts b/server/services/index.ts
index 9157b397..e4929d94 100644
--- a/server/services/index.ts
+++ b/server/services/index.ts
@@ -1,6 +1,7 @@
import { dataAccess } from '../data'
import AdditionalSupportService from './additionalSupportService'
import AuditService from './auditService'
+import FrontendComponentsService from './frontendComponentsService'
import PrisonerProfileService from './prisonerProfileService'
import PrisonerSearchService from './prisonerSearchService'
import PrisonerVisitorsService from './prisonerVisitorsService'
@@ -15,6 +16,7 @@ export const services = () => {
hmppsAuthClient,
manageUsersApiClient,
nomisUserRolesApiClient,
+ frontendComponentsClientBuilder,
orchestrationApiClientBuilder,
prisonApiClientBuilder,
prisonerContactRegistryApiClientBuilder,
@@ -28,6 +30,8 @@ export const services = () => {
const auditService = new AuditService()
+ const frontendComponentsService = new FrontendComponentsService(frontendComponentsClientBuilder)
+
const supportedPrisonsService = new SupportedPrisonsService(
orchestrationApiClientBuilder,
prisonRegisterApiClientBuilder,
@@ -69,6 +73,7 @@ export const services = () => {
return {
additionalSupportService,
auditService,
+ frontendComponentsService,
prisonerProfileService,
prisonerSearchService,
prisonerVisitorsService,
@@ -86,6 +91,7 @@ export type Services = ReturnType
export {
AdditionalSupportService,
AuditService,
+ FrontendComponentsService,
PrisonerProfileService,
PrisonerSearchService,
PrisonerVisitorsService,
diff --git a/server/services/testutils/mocks.ts b/server/services/testutils/mocks.ts
index 48e9ae1e..9ce58f37 100644
--- a/server/services/testutils/mocks.ts
+++ b/server/services/testutils/mocks.ts
@@ -19,6 +19,7 @@ jest.mock('../../applicationInfo', () => {
import {
AdditionalSupportService,
AuditService,
+ FrontendComponentsService,
PrisonerProfileService,
PrisonerSearchService,
PrisonerVisitorsService,
@@ -36,6 +37,9 @@ export const createMockAdditionalSupportService = () =>
export const createMockAuditService = () => new AuditService(null) as jest.Mocked
+export const createMockFrontendComponentsService = () =>
+ new FrontendComponentsService(null) as jest.Mocked
+
export const createMockPrisonerProfileService = () =>
new PrisonerProfileService(null, null, null) as jest.Mocked
diff --git a/server/views/layout.njk b/server/views/layout.njk
index 162fda6a..d63ffb88 100644
--- a/server/views/layout.njk
+++ b/server/views/layout.njk
@@ -1,10 +1,21 @@
{% extends "govuk/template.njk" %}
{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
-{% from "govuk/components/footer/macro.njk" import govukFooter %}
{% block head %}
+
+ {% if feComponents.jsIncludes %}
+ {% for js in feComponents.jsIncludes %}
+
+ {% endfor %}
+ {% endif %}
+
+ {% if feComponents.cssIncludes %}
+ {% for css in feComponents.cssIncludes %}
+
+ {% endfor %}
+ {% endif %}
{% endblock %}
{% block pageTitle %}{{pageTitle | default(applicationName)}}{% endblock %}
@@ -28,16 +39,11 @@
{% endblock %}
{% block footer %}
- {{ govukFooter({
- meta: {
- items: [
- {
- href: "https://it-incident-hub.hmpps.service.justice.gov.uk/manage-prison-visits ",
- text: "Get help"
- }
- ]
- }
- }) }}
+ {% if feComponents.footer %}
+ {{ feComponents.footer | safe }}
+ {% else %}
+ {% include "partials/footer.njk" %}
+ {% endif %}
{% endblock %}
{% block bodyEnd %}
diff --git a/server/views/partials/footer.njk b/server/views/partials/footer.njk
new file mode 100644
index 00000000..ff10ee3e
--- /dev/null
+++ b/server/views/partials/footer.njk
@@ -0,0 +1 @@
+