diff --git a/docker-compose.yml b/docker-compose.yml index cd5c1ea..bee9b04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,23 @@ services: - SPRING_PROFILES_ACTIVE=dev - APPLICATION_AUTHENTICATION_UI_ALLOWLIST=0.0.0.0/0 + manage-users-api: + image: quay.io/hmpps/hmpps-manage-users-api:latest + networks: + - hmpps + container_name: manage-users-api_mhaa + depends_on: + - hmpps-auth + ports: + - "9091:8080" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/health" ] + environment: + - SERVER_PORT=8080 + - SPRING_PROFILES_ACTIVE=dev + - HMPPS_AUTH_ENDPOINT_URL=http://hmpps-auth:8080/auth + - EXTERNAL_USERS_ENDPOINT_URL=http://hmpps-external-users-api:8080 + app: build: context: . diff --git a/helm_deploy/hmpps-template-typescript/.helmignore b/helm_deploy/hmpps-template-typescript/.helmignore deleted file mode 100644 index f0c1319..0000000 --- a/helm_deploy/hmpps-template-typescript/.helmignore +++ /dev/null @@ -1,21 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*~ -# Various IDEs -.project -.idea/ -*.tmproj diff --git a/helm_deploy/hmpps-template-typescript/Chart.yaml b/helm_deploy/hmpps-template-typescript/Chart.yaml deleted file mode 100644 index 14b6cbd..0000000 --- a/helm_deploy/hmpps-template-typescript/Chart.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v2 -appVersion: '1.0' -description: A Helm chart for Kubernetes -name: hmpps-template-typescript -version: 0.2.0 -dependencies: - - name: generic-service - version: 2.6.5 - repository: https://ministryofjustice.github.io/hmpps-helm-charts - - name: generic-prometheus-alerts - version: 1.3.3 - repository: https://ministryofjustice.github.io/hmpps-helm-charts diff --git a/helm_deploy/hmpps-template-typescript/values.yaml b/helm_deploy/hmpps-template-typescript/values.yaml deleted file mode 100644 index 3b918b0..0000000 --- a/helm_deploy/hmpps-template-typescript/values.yaml +++ /dev/null @@ -1,67 +0,0 @@ ---- -generic-service: - nameOverride: hmpps-template-typescript - productId: "UNASSIGNED" # productId for the product that this belongs too, i.e. DPS001, see README.md for details - - replicaCount: 4 - - image: - repository: quay.io/hmpps/hmpps-template-typescript - tag: app_version # override at deployment time - port: 3000 - - ingress: - enabled: true - host: app-hostname.local # override per environment - tlsSecretName: hmpps-template-typescript-cert - - livenessProbe: - httpGet: - path: /ping - - readinessProbe: - httpGet: - path: /ping - - custommetrics: - enabled: true - scrapeInterval: 15s - metricsPath: /metrics - metricsPort: 3001 - - # Environment variables to load into the deployment - env: - NODE_ENV: "production" - REDIS_TLS_ENABLED: "true" - TOKEN_VERIFICATION_ENABLED: "true" - APPLICATIONINSIGHTS_CONNECTION_STRING: "InstrumentationKey=$(APPINSIGHTS_INSTRUMENTATIONKEY);IngestionEndpoint=https://northeurope-0.in.applicationinsights.azure.com/;LiveEndpoint=https://northeurope.livediagnostics.monitor.azure.com/" - - # Pre-existing kubernetes secrets to load as environment variables in the deployment. - # namespace_secrets: - # [name of kubernetes secret]: - # [name of environment variable as seen by app]: [key of kubernetes secret to load] - - namespace_secrets: - hmpps-template-typescript: - APPINSIGHTS_INSTRUMENTATIONKEY: "APPINSIGHTS_INSTRUMENTATIONKEY" - API_CLIENT_ID: "API_CLIENT_ID" - API_CLIENT_SECRET: "API_CLIENT_SECRET" - SYSTEM_CLIENT_ID: "SYSTEM_CLIENT_ID" - SYSTEM_CLIENT_SECRET: "SYSTEM_CLIENT_SECRET" - SESSION_SECRET: "SESSION_SECRET" - elasticache-redis: - REDIS_HOST: "primary_endpoint_address" - REDIS_AUTH_TOKEN: "auth_token" - - allowlist: - office: "217.33.148.210/32" - health-kick: "35.177.252.195/32" - petty-france-wifi: "213.121.161.112/28" - global-protect: "35.176.93.186/32" - mojvpn: "81.134.202.29/32" - cloudplatform-live-1: "35.178.209.113/32" - cloudplatform-live-2: "3.8.51.207/32" - cloudplatform-live-3: "35.177.252.54/32" - -generic-prometheus-alerts: - targetApplication: hmpps-template-typescript diff --git a/server/controllers/baseClientController.test.ts b/server/controllers/baseClientController.test.ts index 663a8e4..35934d5 100644 --- a/server/controllers/baseClientController.test.ts +++ b/server/controllers/baseClientController.test.ts @@ -266,4 +266,132 @@ describe('BaseClientController', () => { expect(response.redirect).toHaveBeenCalledWith(`/base-clients/${baseClient.baseClientId}`) }) }) + + describe('create client instance', () => { + it('if success renders the secrets screen', async () => { + // GIVEN the service returns success and a set of secrets + const baseClient = baseClientFactory.build() + baseClientService.getBaseClient.mockResolvedValue(baseClient) + request = createMock({ body: { baseClientId: baseClient.baseClientId } }) + + const secrets = clientSecretsFactory.build() + baseClientService.addClientInstance.mockResolvedValue(secrets) + + // WHEN it is posted + await baseClientController.createClientInstance()(request, response, next) + + // THEN the new base client success page is rendered + expect(response.render).toHaveBeenCalledWith( + 'pages/new-base-client-success.njk', + expect.objectContaining({ secrets }), + ) + }) + }) + + describe('delete client instance', () => { + it.each([ + ['renders one client instance', 1, true], + ['renders multiple client instances', 3, false], + ])(`if %s renders the page with isLastClient %s`, async (_, clientCount, isLastClient) => { + // GIVEN a base client + const baseClient = baseClientFactory.build() + const clients = clientFactory.buildList(clientCount) + const client = clients[0] + baseClientService.getBaseClient.mockResolvedValue(baseClient) + baseClientService.listClientInstances.mockResolvedValue(clients) + + // WHEN the index page is requested + request = createMock({ params: { baseClientId: baseClient.baseClientId, clientId: client.clientId } }) + await baseClientController.displayDeleteClientInstance()(request, response, next) + + // THEN the view base client page is rendered with isLastClient true + expect(response.render).toHaveBeenCalledWith('pages/delete-client-instance.njk', { + baseClient, + clientId: client.clientId, + isLastClient, + error: null, + }) + + // AND the base client is retrieved from the base client service + expect(baseClientService.getBaseClient).toHaveBeenCalledWith(token, baseClient.baseClientId) + + // AND the clients are retrieved from the base client service + expect(baseClientService.listClientInstances).toHaveBeenCalledWith(token, baseClient) + }) + + it(`renders the page with error`, async () => { + // GIVEN a base client + const baseClient = baseClientFactory.build() + const clients = clientFactory.buildList(3) + const client = clients[0] + baseClientService.getBaseClient.mockResolvedValue(baseClient) + baseClientService.listClientInstances.mockResolvedValue(clients) + const errorCode = 'clientIdMismatch' + + // WHEN the index page is requested + request = createMock({ + params: { baseClientId: baseClient.baseClientId, clientId: client.clientId }, + query: { error: errorCode }, + }) + await baseClientController.displayDeleteClientInstance()(request, response, next) + + // THEN the view base client page is rendered with error + const expectedError = 'Client ID does not match' + expect(response.render).toHaveBeenCalledWith('pages/delete-client-instance.njk', { + baseClient, + clientId: client.clientId, + isLastClient: false, + error: expectedError, + }) + }) + + describe(`delete the client instance`, () => { + it.each([ + ['one client instance exists', '/', 1], + ['multiple client instances', '/base-clients/abcd', 3], + ])(`if delete successful and %s, redirects to %s`, async (_, redirectURL, clientCount) => { + // GIVEN a base client + const baseClient = baseClientFactory.build({ baseClientId: 'abcd' }) + const clients = clientFactory.buildList(clientCount) + const client = clients[0] + baseClientService.getBaseClient.mockResolvedValue(baseClient) + baseClientService.listClientInstances.mockResolvedValue(clients) + + // WHEN a delete request is made + request = createMock({ + params: { baseClientId: baseClient.baseClientId, clientId: client.clientId }, + query: { error: null }, + body: { confirm: client.clientId }, + }) + await baseClientController.deleteClientInstance()(request, response, next) + + // THEN the client instance is deleted + expect(baseClientService.deleteClientInstance).toHaveBeenCalledWith(token, client) + + // AND the user is redirected + expect(response.redirect).toHaveBeenCalledWith(redirectURL) + }) + + it(`if client does not match, redirects with error`, async () => { + // GIVEN a base client + const baseClient = baseClientFactory.build({ baseClientId: 'abcd' }) + const clients = clientFactory.buildList(3) + const client = clients[0] + baseClientService.getBaseClient.mockResolvedValue(baseClient) + baseClientService.listClientInstances.mockResolvedValue(clients) + + // WHEN a delete request is made + request = createMock({ + params: { baseClientId: baseClient.baseClientId, clientId: client.clientId }, + query: { error: null }, + body: { confirm: 'something incorrect' }, + }) + await baseClientController.deleteClientInstance()(request, response, next) + + // THEN the user is redirected with error + const expectedURL = `/base-clients/${baseClient.baseClientId}/clients/${client.clientId}/delete?error=clientIdMismatch` + expect(response.redirect).toHaveBeenCalledWith(expectedURL) + }) + }) + }) }) diff --git a/server/controllers/baseClientController.ts b/server/controllers/baseClientController.ts index ff732d4..aef6a3d 100644 --- a/server/controllers/baseClientController.ts +++ b/server/controllers/baseClientController.ts @@ -6,6 +6,7 @@ import nunjucksUtils from '../views/helpers/nunjucksUtils' import { mapCreateBaseClientForm, mapEditBaseClientDeploymentForm, mapEditBaseClientDetailsForm } from '../mappers' import { BaseClient } from '../interfaces/baseClientApi/baseClient' import editBaseClientPresenter from '../views/presenters/editBaseClientPresenter' +import mapFilterForm from '../mappers/forms/mapFilterForm' export default class BaseClientController { constructor(private readonly baseClientService: BaseClientService) {} @@ -23,6 +24,21 @@ export default class BaseClientController { } } + public filterBaseClients(): RequestHandler { + return async (req, res) => { + const userToken = res.locals.user.token + const filter = mapFilterForm(req) + + const baseClients = await this.baseClientService.listBaseClients(userToken) + + const presenter = listBaseClientsPresenter(baseClients, filter) + + res.render('pages/base-clients.njk', { + presenter, + }) + } + } + public displayBaseClient(): RequestHandler { return async (req, res) => { const userToken = res.locals.user.token @@ -159,4 +175,78 @@ export default class BaseClientController { res.redirect(`/base-clients/${baseClientId}`) } } + + public createClientInstance(): RequestHandler { + return async (req, res, next) => { + const userToken = res.locals.user.token + const { baseClientId } = req.params + + // get base client + const baseClient = await this.baseClientService.getBaseClient(userToken, baseClientId) + + // Create base client + const secrets = await this.baseClientService.addClientInstance(userToken, baseClient) + + // Display success page + res.render('pages/new-base-client-success.njk', { + title: `Client has been added`, + baseClientId: baseClient.baseClientId, + secrets, + }) + } + } + + public displayDeleteClientInstance(): RequestHandler { + return async (req, res, next) => { + const userToken = res.locals.user.token + const { baseClientId, clientId } = req.params + const error = req.query.error === 'clientIdMismatch' ? 'Client ID does not match' : null + + // get base client + const baseClient = await this.baseClientService.getBaseClient(userToken, baseClientId) + const clients = await this.baseClientService.listClientInstances(userToken, baseClient) + + // Display delete confirmation page + res.render('pages/delete-client-instance.njk', { + baseClient, + clientId, + isLastClient: clients.length === 1, + error, + }) + } + } + + public deleteClientInstance(): RequestHandler { + return async (req, res, next) => { + const userToken = res.locals.user.token + const { baseClientId, clientId } = req.params + + // check client id matches + if (req.body.confirm !== clientId) { + res.redirect(`/base-clients/${baseClientId}/clients/${clientId}/delete?error=clientIdMismatch`) + return + } + + // get base client + const baseClient = await this.baseClientService.getBaseClient(userToken, baseClientId) + const clients = await this.baseClientService.listClientInstances(userToken, baseClient) + const client = clients.find(c => c.clientId === clientId) + + // check client exists + if (!client) { + res.redirect(`/base-clients/${baseClientId}/clients/${clientId}/delete?error=clientNotFound`) + return + } + + // delete client + await this.baseClientService.deleteClientInstance(userToken, client) + + // return to view base client screen (or home screen if last client deleted) + if (clients.length === 1) { + res.redirect(`/`) + } else { + res.redirect(`/base-clients/${baseClientId}`) + } + } + } } diff --git a/server/interfaces/baseClientApi/baseClient.ts b/server/interfaces/baseClientApi/baseClient.ts index 7734c5e..4e7421f 100644 --- a/server/interfaces/baseClientApi/baseClient.ts +++ b/server/interfaces/baseClientApi/baseClient.ts @@ -56,3 +56,12 @@ export interface ClientSecrets { base64ClientId: string base64ClientSecret: string } + +export interface BaseClientListFilter { + roleSearch: string + clientCredentials: boolean + authorisationCode: boolean + serviceClientType: boolean + personalClientType: boolean + blankClientType: boolean +} diff --git a/server/mappers/forms/mapFilterForm.ts b/server/mappers/forms/mapFilterForm.ts new file mode 100644 index 0000000..8f1103e --- /dev/null +++ b/server/mappers/forms/mapFilterForm.ts @@ -0,0 +1,16 @@ +import { Request } from 'express' +import { BaseClientListFilter } from '../../interfaces/baseClientApi/baseClient' + +export default (request: Request): BaseClientListFilter => { + // valid days is calculated from expiry date + const data = request.body + + return { + roleSearch: data.role.trim(), + clientCredentials: data.grantType ? data.grantType.includes('client-credentials') : true, + authorisationCode: data.grantType ? data.grantType.includes('authorization-code') : true, + serviceClientType: data.clientType ? data.clientType.includes('service') : true, + personalClientType: data.clientType ? data.clientType.includes('personal') : true, + blankClientType: data.clientType ? data.clientType.includes('blank') : true, + } +} diff --git a/server/routes/baseClientRouter.ts b/server/routes/baseClientRouter.ts index e0b2477..228e717 100644 --- a/server/routes/baseClientRouter.ts +++ b/server/routes/baseClientRouter.ts @@ -25,8 +25,12 @@ export default function baseClientRouter(services: Services): Router { get('/base-clients/:baseClientId/deployment', baseClientController.displayEditBaseClientDeployment()) get('/base-clients/:baseClientId/edit', baseClientController.displayEditBaseClient()) get('/base-clients/:baseClientId', baseClientController.displayBaseClient()) + get('/base-clients/:baseClientId/clients/:clientId/delete', baseClientController.displayDeleteClientInstance()) + post('/', baseClientController.filterBaseClients()) post('/base-clients/new', baseClientController.createBaseClient()) post('/base-clients/:baseClientId/deployment', baseClientController.updateBaseClientDeployment()) post('/base-clients/:baseClientId/edit', baseClientController.updateBaseClientDetails()) + post('/base-clients/:baseClientId/clients', baseClientController.createClientInstance()) + post('/base-clients/:baseClientId/clients/:clientId/delete', baseClientController.deleteClientInstance()) return router } diff --git a/server/testutils/factories/filter.ts b/server/testutils/factories/filter.ts new file mode 100644 index 0000000..15af529 --- /dev/null +++ b/server/testutils/factories/filter.ts @@ -0,0 +1,11 @@ +import { Factory } from 'fishery' +import { BaseClientListFilter } from '../../interfaces/baseClientApi/baseClient' + +export default Factory.define(() => ({ + roleSearch: '', + clientCredentials: true, + authorisationCode: true, + serviceClientType: true, + personalClientType: true, + blankClientType: true, +})) diff --git a/server/testutils/factories/index.ts b/server/testutils/factories/index.ts index 1159afe..41044a6 100644 --- a/server/testutils/factories/index.ts +++ b/server/testutils/factories/index.ts @@ -10,6 +10,7 @@ import updateBaseClientRequestFactory from './requests/updateBaseClientRequest' import updateBaseClientDeploymentFactory from './requests/updateBaseClientDeploymentRequest' import listClientInstancesResponseFactory from './responses/listClientInstancesResponse' import clientSecretsFactory from './secrets' +import filterFactory from './filter' export { baseClientFactory, @@ -22,4 +23,5 @@ export { updateBaseClientRequestFactory, updateBaseClientDeploymentFactory, listClientInstancesResponseFactory, + filterFactory, } diff --git a/server/views/pages/base-client.njk b/server/views/pages/base-client.njk index 83d27b8..e368421 100644 --- a/server/views/pages/base-client.njk +++ b/server/views/pages/base-client.njk @@ -4,10 +4,9 @@ {% set mainClasses = "app-container govuk-body" %} {% set pageName="Home" %} -{% set bodyClasses = "extra-wide" %} {% block header %} - {% include "partials/header.njk" %} + {% include "partials/header.njk" %} {% endblock %} {%- from "moj/components/header/macro.njk" import mojHeader -%} @@ -22,370 +21,371 @@ {%- from "govuk/components/table/macro.njk" import govukTable -%} {% block content %} - {{ govukBackLink({ - text: "Back", - href: "/" - }) }} + {{ govukBackLink({ + text: "Back", + href: "/" + }) }} -
-
- -

- Client: {{ baseClient.baseClientId }} -

+
+
- {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Client ID" - },{ - text: "Created" - },{ - text: "Last accessed" - },{ - text: "" - } - ], - rows: presenter.clientsTable - }) }} +

+ Client: {{ baseClient.baseClientId }} +

-
-
- {{ govukButton({ - text: "Add new client" - }) }} -
-
+ {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Client ID" + },{ + text: "Created" + },{ + text: "Last accessed" + },{ + text: "" + } + ], + rows: presenter.clientsTable + }) }} -
-
-

Base client details

-
- -
+
+ +
+ {{ govukButton({ + text: "Add new client" + }) }} +
+
- {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Base client" - },{ - text: baseClient.baseClientId - }], - rows: [ - [ - { - text: "Client type" - },{ - text: capitalize(baseClient.clientType) - } - ], - [ - { - text: "Access token validity" - },{ - text: baseClient.accessTokenValidity - } - ],[ - { - text: "Approved scopes" - },{ - html: toLinesHtml(baseClient.scopes) - } - ] - ] - }) }} +
+
+

Base client details

+
+ +
- {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Audit trail" - },{ - text: "" - }], - rows: [ - [{ - text: "Details" - },{ - text: baseClient.audit - }] - ] - }) }} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Base client" + },{ + text: baseClient.baseClientId + }], + rows: [ + [ + { + text: "Client type" + },{ + text: capitalize(baseClient.clientType) + } + ], + [ + { + text: "Access token validity" + },{ + text: baseClient.accessTokenValidity + } + ],[ + { + text: "Approved scopes" + },{ + html: toLinesHtml(baseClient.scopes) + } + ] + ] + }) }} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Audit trail" + },{ + text: "" + }], + rows: [ + [{ + text: "Details" + },{ + text: baseClient.audit + }] + ] + }) }} - {% if baseClient.grantType == "client_credentials" %} - {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Grant details" - },{ - text: "" - }], - rows: [ - [ - { - text: "Grant type" - },{ - text: "Client credentials" - } - ],[ - { - text: "Roles" - },{ - html: toLinesHtml(baseClient.clientCredentials.authorities) - } - ],[ - { - text: "Database username" - },{ - text: baseClient.clientCredentials.databaseUserName - } - ] - ] - }) }} - {% endif %} - {% if baseClient.grantType == "authorization_code" %} - {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Grant details" - },{ - text: "" - }], - rows: [ - [ - { - text: "Grant type" - },{ - text: "Authorization code" - } - ],[ - { - text: "Registered redirect URIs" - },{ - text: toLinesHtml(baseClient.authorisationCode.registeredRedirectURIs) - } - ],[ - { - text: "JWT Fields Configuration" - },{ - text: baseClient.authorisationCode.jwtFields - } - ],[ - { - text: "Azure Ad login flow" - },{ - text: presenter.skipToAzureField - } - ] - ] - }) }} - {% endif %} + {% if baseClient.grantType == "client_credentials" %} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Grant details" + },{ + text: "" + }], + rows: [ + [ + { + text: "Grant type" + },{ + text: "Client credentials" + } + ],[ + { + text: "Roles" + },{ + html: toLinesHtml(baseClient.clientCredentials.authorities) + } + ],[ + { + text: "Database username" + },{ + text: baseClient.clientCredentials.databaseUserName + } + ] + ] + }) }} + {% endif %} + {% if baseClient.grantType == "authorization_code" %} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Grant details" + },{ + text: "" + }], + rows: [ + [ + { + text: "Grant type" + },{ + text: "Authorization code" + } + ],[ + { + text: "Registered redirect URIs" + },{ + text: toLinesHtml(baseClient.authorisationCode.registeredRedirectURIs) + } + ],[ + { + text: "JWT Fields Configuration" + },{ + text: baseClient.authorisationCode.jwtFields + } + ],[ + { + text: "Azure Ad login flow" + },{ + text: presenter.skipToAzureField + } + ] + ] + }) }} + {% endif %} - {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Config" - },{ - text: "" - }], - rows: [ - [ - { - text: "Allow client to expire" - },{ - text: presenter.expiry - } - ],[ - { - text: "Allowed IPs" - },{ - html: toLinesHtml(baseClient.config.allowedIPs) - } - ] - ] - }) }} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Config" + },{ + text: "" + }], + rows: [ + [ + { + text: "Allow client to expire" + },{ + text: presenter.expiry + } + ],[ + { + text: "Allowed IPs" + },{ + html: toLinesHtml(baseClient.config.allowedIPs) + } + ] + ] + }) }} - {% if baseClient.grantType == "authorization_code" %} -
-
-

Service details

-
- -
- {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Service details" - },{ - text: "" - }], - rows: [ - [ - { - text: "Name" - },{ - text: baseClient.serviceDetails.serviceName - } - ],[ - { - text: "Description" - },{ - text: baseClient.serviceDetails.serviceDescription - } - ],[ - { - text: "Authorised roles" - },{ - text: baseClient.serviceDetails.serviceAuthorisedRoles.join('
') - } - ],[ - { - text: "URL" - },{ - text: baseClient.serviceDetails.serviceURL - } - ],[ - { - text: "Contact URL/email" - },{ - text: baseClient.serviceDetails.contactUsURL - } - ],[ - { - text: "Status" - },{ - text: presenter.serviceEnabledLabel - } - ] - ] - }) }} - {% endif %} + {% if baseClient.grantType == "authorization_code" %} +
+
+

Service details

+
+ +
-
-
-

Deployment details

-
- -
+ {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Service details" + },{ + text: "" + }], + rows: [ + [ + { + text: "Name" + },{ + text: baseClient.serviceDetails.serviceName + } + ],[ + { + text: "Description" + },{ + text: baseClient.serviceDetails.serviceDescription + } + ],[ + { + text: "Authorised roles" + },{ + text: baseClient.serviceDetails.serviceAuthorisedRoles.join('
') + } + ],[ + { + text: "URL" + },{ + text: baseClient.serviceDetails.serviceURL + } + ],[ + { + text: "Contact URL/email" + },{ + text: baseClient.serviceDetails.contactUsURL + } + ],[ + { + text: "Status" + },{ + text: presenter.serviceEnabledLabel + } + ] + ] + }) }} + {% endif %} - {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Contact" - },{ - text: "" - }], - rows: [ - [ - { - text: "Team" - },{ - text: baseClient.deployment.team - } - ], - [ - { - text: "Team contact" - },{ - text: baseClient.deployment.teamContact - } - ], - [ - { - text: "Team slack" - },{ - text: baseClient.deployment.teamSlack - } - ] - ] - }) }} +
+
+

Deployment details

+
+ +
- {{ govukTable({ - firstCellIsHeader: false, - head: [ - { - text: "Platform" - },{ - text: "" - }], - rows: [ - [ - { - text: "Hosting" - },{ - text: baseClient.deployment.hosting - } - ], - [ - { - text: "Namespace" - },{ - text: baseClient.deployment.namespace - } - ], - [ - { - text: "Deployment" - },{ - text: baseClient.deployment.deployment - } - ], - [ - { - text: "Secret name" - },{ - text: baseClient.deployment.secretName - } - ], - [ - { - text: "Client id key" - },{ - text: baseClient.deployment.clientIdKey - } - ], - [ - { - text: "Secret key" - },{ - text: baseClient.deployment.secretKey - } - ], - [ - { - text: "Deployment info" - },{ - text: baseClient.deployment.deploymentInfo - } - ] + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Contact" + },{ + text: "" + }], + rows: [ + [ + { + text: "Team" + },{ + text: baseClient.deployment.team + } + ], + [ + { + text: "Team contact" + },{ + text: baseClient.deployment.teamContact + } + ], + [ + { + text: "Team slack" + },{ + text: baseClient.deployment.teamSlack + } + ] + ] + }) }} - ] - }) }} + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Platform" + },{ + text: "" + }], + rows: [ + [ + { + text: "Hosting" + },{ + text: baseClient.deployment.hosting + } + ], + [ + { + text: "Namespace" + },{ + text: baseClient.deployment.namespace + } + ], + [ + { + text: "Deployment" + },{ + text: baseClient.deployment.deployment + } + ], + [ + { + text: "Secret name" + },{ + text: baseClient.deployment.secretName + } + ], + [ + { + text: "Client id key" + },{ + text: baseClient.deployment.clientIdKey + } + ], + [ + { + text: "Secret key" + },{ + text: baseClient.deployment.secretKey + } + ], + [ + { + text: "Deployment info" + },{ + text: baseClient.deployment.deploymentInfo + } + ] -
+ ] + }) }}
+
+ {% endblock %} diff --git a/server/views/pages/base-clients.njk b/server/views/pages/base-clients.njk index d53c51f..3c0d4c6 100644 --- a/server/views/pages/base-clients.njk +++ b/server/views/pages/base-clients.njk @@ -32,7 +32,8 @@ label: { text: 'Role', classes: 'govuk-label--m' - } + }, + value: presenter.filter.roleSearch }) }} {{ govukCheckboxes({ @@ -49,12 +50,12 @@ { value: 'client-credentials', text: 'Client credentials', - checked: true + checked: presenter.filter.clientCredentials }, { value: 'authorization-code', text: 'Authorization code', - checked: true + checked: presenter.filter.authorisationCode } ] }) }} @@ -73,17 +74,17 @@ { value: 'service', text: 'Service', - checked: true + checked: presenter.filter.serviceClientType }, { value: 'personal', text: 'Personal', - checked: true + checked: presenter.filter.personalClientType }, { value: 'blank', text: '[blank]', - checked: true + checked: presenter.filter.blankClientType } ] }) }} @@ -94,10 +95,10 @@ {% block content %} -
+
- + {{ mojFilter({ heading: { text: 'Filter' @@ -142,7 +143,7 @@ }) }}
-
+ {% endblock %} {% block pageScripts %} diff --git a/server/views/pages/delete-client-instance.njk b/server/views/pages/delete-client-instance.njk new file mode 100644 index 0000000..47518d0 --- /dev/null +++ b/server/views/pages/delete-client-instance.njk @@ -0,0 +1,68 @@ +{% extends "../partials/layout.njk" %} + +{% set pageTitle = applicationName + " - Home" %} +{% set mainClasses = "app-container govuk-body" %} + +{% set pageName="Delete client?" %} + +{% block header %} + {% include "partials/header.njk" %} +{% endblock %} + +{%- from "moj/components/header/macro.njk" import mojHeader -%} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/warning-text/macro.njk" import govukWarningText %} + +{% block content %} + +
+

Delete client?

+ +
+
+ + {% if isLastClient %} + {{ govukWarningText({ + text: "Deleting this client will also delete base-client '" + baseClient.baseClientId + "'.", + iconFallbackText: "Warning" + }) }} + {% endif %} + {{ govukWarningText({ + text: "Deleted clients cannot be restored!", + iconFallbackText: "Warning" + }) }} + + +
+ + {{ govukInput({ + label: { + text: "Confirmation" + }, + hint: { + html: "Type \"" + clientId + "\" to confirm" + }, + errorMessage: { + text: error + }, + classes: "govuk-!-width-two-thirds", + id: "confirm", + name: "confirm" + }) }} + + {{ govukButton({ + text: "Delete client instance", + type: "submit", + classes: "govuk-button--warning" + }) }} + {{ govukButton({ + text: "Cancel", + href: "/base-clients/" + baseClient.baseClientId + }) }} +
+
+
+
+ +{% endblock %} diff --git a/server/views/presenters/listBaseClientsPresenter.test.ts b/server/views/presenters/listBaseClientsPresenter.test.ts index 80048ee..23793d8 100644 --- a/server/views/presenters/listBaseClientsPresenter.test.ts +++ b/server/views/presenters/listBaseClientsPresenter.test.ts @@ -1,6 +1,6 @@ import { BaseClient } from '../../interfaces/baseClientApi/baseClient' -import { baseClientFactory } from '../../testutils/factories' -import listBaseClientsPresenter from './listBaseClientsPresenter' +import { baseClientFactory, filterFactory } from '../../testutils/factories' +import listBaseClientsPresenter, { filterBaseClient } from './listBaseClientsPresenter' let baseClients: BaseClient[] @@ -22,7 +22,7 @@ describe('listBaseClientsPresenter', () => { it('contains a constant for table head', () => { // When we map to a presenter - const presenter = listBaseClientsPresenter(baseClients) + const presenter = listBaseClientsPresenter(baseClients, filterFactory.build()) // Then it contains a table head constant expect(presenter.tableHead).not.toBeNull() @@ -31,11 +31,11 @@ describe('listBaseClientsPresenter', () => { describe('tableHeadRows', () => { it('maps a link to the view page in the first column', () => { // When we map to a presenter - const presenter = listBaseClientsPresenter(baseClients) + const presenter = listBaseClientsPresenter(baseClients, filterFactory.build()) const expected = [ - 'baseClientIdA', - 'baseClientIdB', + "baseClientIdA", + "baseClientIdB", ] const actual = presenter.tableRows.map(row => row[0].html) expect(actual).toEqual(expected) @@ -44,7 +44,7 @@ describe('listBaseClientsPresenter', () => { it('maps moj-badge for count to the second column if count > 1', () => { // When we map to a presenter const presenter = listBaseClientsPresenter(baseClients) - const expected = ['', '2'] + const expected = ['', "2"] const actual = presenter.tableRows.map(row => row[1].html) expect(actual).toEqual(expected) }) @@ -57,4 +57,53 @@ describe('listBaseClientsPresenter', () => { expect(actual).toEqual(expected) }) }) + + describe('filter', () => { + describe('by role - free text search', () => { + it('matches if the free text matches a client credentials authority', () => { + const filter = filterFactory.build({ roleSearch: 'ONE' }) + const baseClient = baseClientFactory.build({ clientCredentials: { authorities: ['ONE', 'TWO'] } }) + + const passesFilter = filterBaseClient(baseClient, filter) + + expect(passesFilter).toBeTruthy() + }) + + it('is case insensitive', () => { + const filter = filterFactory.build({ roleSearch: 'onE' }) + const baseClient = baseClientFactory.build({ clientCredentials: { authorities: ['ONE', 'TWO'] } }) + + const passesFilter = filterBaseClient(baseClient, filter) + + expect(passesFilter).toBeTruthy() + }) + + it('ignores whitespace', () => { + const filter = filterFactory.build({ roleSearch: ' one ' }) + const baseClient = baseClientFactory.build({ clientCredentials: { authorities: ['ONE', 'TWO'] } }) + + const passesFilter = filterBaseClient(baseClient, filter) + + expect(passesFilter).toBeTruthy() + }) + + it('does not filter if search is empty', () => { + const filter = filterFactory.build({ roleSearch: '' }) + const baseClient = baseClientFactory.build({ clientCredentials: { authorities: ['ONE', 'TWO'] } }) + + const passesFilter = filterBaseClient(baseClient, filter) + + expect(passesFilter).toBeTruthy() + }) + + it('does not matches if the free text not contained in a client credentials authority', () => { + const filter = filterFactory.build({ roleSearch: 'ONE' }) + const baseClient = baseClientFactory.build({ clientCredentials: { authorities: ['alpha', 'beta'] } }) + + const passesFilter = filterBaseClient(baseClient, filter) + + expect(passesFilter).toBeFalsy() + }) + }) + }) }) diff --git a/server/views/presenters/listBaseClientsPresenter.ts b/server/views/presenters/listBaseClientsPresenter.ts index 8685ff6..9fc0822 100644 --- a/server/views/presenters/listBaseClientsPresenter.ts +++ b/server/views/presenters/listBaseClientsPresenter.ts @@ -1,4 +1,4 @@ -import { BaseClient } from '../../interfaces/baseClientApi/baseClient' +import { BaseClient, BaseClientListFilter } from '../../interfaces/baseClientApi/baseClient' import { convertToTitleCase } from '../../utils/utils' const indexTableHead = () => { @@ -69,13 +69,15 @@ const indexTableHead = () => { ] } -const indexTableRows = (data: BaseClient[]) => { - return data.map(item => [ +const indexTableRows = (data: BaseClient[], filter?: BaseClientListFilter) => { + const dataItems = filterItems(data, filter) + + return dataItems.map(item => [ { - html: `${item.baseClientId}`, + html: `${item.baseClientId}`, }, { - html: item.count > 1 ? `${item.count}` : '', + html: item.count > 1 ? `${item.count}` : '', }, { text: item.clientType ? convertToTitleCase(item.clientType) : '', @@ -101,9 +103,47 @@ const indexTableRows = (data: BaseClient[]) => { ]) } -export default (data: BaseClient[]) => { +const filterItems = (data: BaseClient[], filter?: BaseClientListFilter) => { + return filter ? data.filter(item => filterBaseClient(item, filter)) : data +} + +export const filterBaseClient = (baseClient: BaseClient, filter: BaseClientListFilter) => { + if (filter.roleSearch) { + const roles = baseClient.clientCredentials.authorities.join(' ').toLowerCase() + const roleSearch = filter.roleSearch.toLowerCase().trim() + if (roles.includes(roleSearch) === false) { + return false + } + } + + if (baseClient.grantType === 'client_credentials' && !filter.clientCredentials) { + return false + } + + if (baseClient.grantType === 'authorisation_code' && !filter.authorisationCode) { + return false + } + + if (baseClient.clientType === 'PERSONAL' && !filter.personalClientType) { + return false + } + if (baseClient.clientType === 'SERVICE' && !filter.serviceClientType) { + return false + } + return filter.blankClientType +} + +export default (data: BaseClient[], filter?: BaseClientListFilter) => { return { tableHead: indexTableHead(), - tableRows: indexTableRows(data), + tableRows: indexTableRows(data, filter), + filter: filter || { + roleSearch: '', + clientCredentials: true, + authorisationCode: true, + serviceClientType: true, + personalClientType: true, + blankClientType: true, + }, } } diff --git a/server/views/presenters/viewBaseClientPresenter.test.ts b/server/views/presenters/viewBaseClientPresenter.test.ts index 274c118..b3d680e 100644 --- a/server/views/presenters/viewBaseClientPresenter.test.ts +++ b/server/views/presenters/viewBaseClientPresenter.test.ts @@ -43,8 +43,8 @@ describe('viewBaseClientPresenter', () => { // Then the dates are formatted as DD/MM/YYYY const expected = [ - 'delete', - 'delete', + 'delete', + 'delete', ] const actual = presenter.clientsTable.map(row => row[3].html) expect(expected).toEqual(actual) diff --git a/server/views/presenters/viewBaseClientPresenter.ts b/server/views/presenters/viewBaseClientPresenter.ts index 11c213f..90839f8 100644 --- a/server/views/presenters/viewBaseClientPresenter.ts +++ b/server/views/presenters/viewBaseClientPresenter.ts @@ -15,7 +15,7 @@ export default (baseClient: BaseClient, clients: Client[]) => { html: item.accessed.toLocaleDateString('en-GB'), }, { - html: `delete`, + html: `delete`, }, ]), expiry: baseClient.config.expiryDate ? `Yes - days remaining ${daysRemaining(baseClient.config.expiryDate)}` : 'No',