diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ec04bf6..8b155c09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,8 @@ jobs: run: npm ci - name: Build tsoa run: npm run tsoa:build + - name: touch env + run: touch .env - name: Setup dependencies run: docker compose up -d - name: Sleep diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fdbf8b63..f4b0463a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,6 +72,8 @@ jobs: run: npm ci - name: Build tsoa run: npm run tsoa:build + - name: touch env + run: touch .env - name: Setup dependencies run: docker compose up -d - name: Sleep diff --git a/.gitignore b/.gitignore index e375db01..4f160ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ npm-debug.log* *.seed *.pid.lock .tsimp + +.DS_Store diff --git a/Dockerfile b/Dockerfile index 945073b9..1acaa534 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ COPY package*.json ./ RUN npm ci --omit-dev COPY public ./public +COPY knexfile.js ./ COPY --from=builder /veritable-ui/build ./build EXPOSE 80 diff --git a/README.md b/README.md index d78f9602..573ad0c5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ And then to building the `tsoa` routes: npm run tsoa:build ``` -Setup service dependencies +To setup service dependencies make sure there's at a minimum an empty `.env` file in the root directory of this repository and then run: ```bash docker compose up -d diff --git a/docker-compose.yml b/docker-compose.yml index a3578c1e..b23d33fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,5 @@ services: - postgres-veritable-ui: - image: postgres:16.3-alpine - container_name: postgres-veritable-ui - ports: - - 5432:5432 - volumes: - - postgres-veritable-ui:/var/lib/postgresql/data - environment: - - POSTGRES_PASSWORD=postgres - - POSTGRES_USER=postgres - - POSTGRES_DB=veritable-ui + # -------------------- shared -------------------------------# keycloak: image: quay.io/keycloak/keycloak:24.0.5 container_name: keycloak @@ -26,39 +16,134 @@ services: image: ipfs/kubo:release volumes: - ipfs:/data/ipfs - postgres: - container_name: postgres-veritable-cloudagent + + # -------------------- alice -------------------------------# + postgres-veritable-ui-alice: + image: postgres:16.3-alpine + container_name: postgres-veritable-ui-alice + ports: + - 5432:5432 + volumes: + - postgres-veritable-ui-alice:/var/lib/postgresql/data + environment: + - POSTGRES_PASSWORD=postgres + - POSTGRES_USER=postgres + - POSTGRES_DB=veritable-ui + postgres-veritable-cloudagent-alice: + container_name: postgres-veritable-cloudagent-alice image: postgres:16.3-alpine restart: on-failure volumes: - - postgres-veritable-cloudagent:/var/lib/postgresql/data + - postgres-veritable-cloudagent-alice:/var/lib/postgresql/data environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_DB=postgres-veritable-cloudagent - veritable-cloudagent: + veritable-cloudagent-alice: image: digicatapult/veritable-cloudagent - container_name: veritable-cloudagent + container_name: veritable-cloudagent-alice restart: always depends_on: ipfs: condition: service_healthy ports: + - 3100:3000 + command: --inbound-transport http 5002 ws 5003 --outbound-transport http ws + environment: + # - AFJ_REST_INBOUND_TRANSPORT="http 5002 ws 5003" + # - AFJ_REST_OUTBOUND_TRANSPORT="http ws" + - AFJ_REST_ENDPOINT=ws://veritable-cloudagent-alice:5003 + - AFJ_REST_ADMIN_PORT=3000 + - AFJ_REST_IPFS_ORIGIN=http://ipfs:5001 + - AFJ_REST_POSTGRES_HOST=postgres-veritable-cloudagent-alice + - AFJ_REST_POSTGRES_PORT=5432 + - AFJ_REST_POSTGRES_USERNAME=postgres + - AFJ_REST_POSTGRES_PASSWORD=postgres + - AFJ_REST_LABEL=vertiable-cloudagent + - AFJ_REST_WALLET_ID=alice + - AFJ_REST_WALLET_KEY=alice-key + + # -------------------- bob -------------------------------# + veritable-ui-bob: + container_name: veritable-ui-bob + build: + context: . + dockerfile: Dockerfile + restart: no + depends_on: + - postgres-veritable-ui-bob + - veritable-cloudagent-bob + ports: - 3001:3000 + command: > + sh -c " + npm i -g pino-colada + node ./node_modules/.bin/knex migrate:latest + npm start | pino-colada" + env_file: + - docker/test.env + - .env + environment: + - NODE_ENV=production + - LOG_LEVEL=trace + - DB_HOST=postgres-veritable-ui-bob + - DB_NAME=veritable-ui + - PUBLIC_URL=http://localhost:3001 + - CLOUDAGENT_ADMIN_ORIGIN=http://veritable-cloudagent-bob:3000 + - COOKIE_SESSION_KEYS=secret + - DB_PASSWORD=postgres + - DB_USERNAME=postgres + - IDP_CLIENT_ID=veritable-ui + - IDP_PUBLIC_URL_PREFIX=http://localhost:3080/realms/veritable/protocol/openid-connect + - IDP_INTERNAL_URL_PREFIX=http://keycloak:8080/realms/veritable/protocol/openid-connect + - INVITATION_FROM_COMPANY_NUMBER=07964699 + - INVITATION_PIN_SECRET=secret + postgres-veritable-ui-bob: + image: postgres:16.3-alpine + container_name: postgres-veritable-ui-bob + volumes: + - postgres-veritable-ui-bob:/var/lib/postgresql/data + environment: + - POSTGRES_PASSWORD=postgres + - POSTGRES_USER=postgres + - POSTGRES_DB=veritable-ui + postgres-veritable-cloudagent-bob: + container_name: postgres-veritable-cloudagent-bob + image: postgres:16.3-alpine + restart: on-failure + volumes: + - postgres-veritable-cloudagent-bob:/var/lib/postgresql/data + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres-veritable-cloudagent + veritable-cloudagent-bob: + image: digicatapult/veritable-cloudagent + container_name: veritable-cloudagent-bob + restart: always + depends_on: + ipfs: + condition: service_healthy + ports: + - 3101:3000 + command: --inbound-transport http 5002 ws 5003 --outbound-transport http ws environment: - - AFJ_REST_ENDPOINT=ws://veritable-cloudagent:5003 + # - AFJ_REST_INBOUND_TRANSPORT="http 5002 ws 5003" + # - AFJ_REST_OUTBOUND_TRANSPORT="http ws" + - AFJ_REST_ENDPOINT=ws://veritable-cloudagent-bob:5003 - AFJ_REST_ADMIN_PORT=3000 - - AFJ_REST_INBOUND_TRANSPORTS="ws 5003" - AFJ_REST_IPFS_ORIGIN=http://ipfs:5001 - - AFJ_REST_POSTGRES_HOST=postgres + - AFJ_REST_POSTGRES_HOST=postgres-veritable-cloudagent-bob - AFJ_REST_POSTGRES_PORT=5432 - AFJ_REST_POSTGRES_USERNAME=postgres - AFJ_REST_POSTGRES_PASSWORD=postgres - AFJ_REST_LABEL=vertiable-cloudagent - - AFJ_REST_WALLET_ID=walletId - - AFJ_REST_WALLET_KEY=walletKey + - AFJ_REST_WALLET_ID=bob + - AFJ_REST_WALLET_KEY=bob-key volumes: ipfs: - postgres-veritable-ui: - postgres-veritable-cloudagent: + postgres-veritable-ui-alice: + postgres-veritable-ui-bob: + postgres-veritable-cloudagent-alice: + postgres-veritable-cloudagent-bob: diff --git a/docker/keycloak/veritable.json b/docker/keycloak/veritable.json index 122f164f..48da2f42 100644 --- a/docker/keycloak/veritable.json +++ b/docker/keycloak/veritable.json @@ -404,8 +404,13 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "redirectUris": ["http://localhost:3000/auth/redirect", "http://localhost:3000/swagger/oauth2-redirect.html"], - "webOrigins": ["http://localhost:3000"], + "redirectUris": [ + "http://localhost:3000/auth/redirect", + "http://localhost:3000/swagger/oauth2-redirect.html", + "http://localhost:3001/auth/redirect", + "http://localhost:3001/swagger/oauth2-redirect.html" + ], + "webOrigins": ["http://localhost:3000", "http://localhost:3001"], "notBefore": 0, "bearerOnly": false, "consentRequired": false, diff --git a/docker/test.env b/docker/test.env new file mode 100644 index 00000000..8aeca259 --- /dev/null +++ b/docker/test.env @@ -0,0 +1 @@ +COMPANY_PROFILE_API_KEY=API_KEY diff --git a/src/models/db/knexfile.ts b/knexfile.js similarity index 85% rename from src/models/db/knexfile.ts rename to knexfile.js index c08e0cfa..5151fc34 100644 --- a/src/models/db/knexfile.ts +++ b/knexfile.js @@ -1,5 +1,3 @@ -import type { Knex } from 'knex' - export const pgConfig = { client: 'pg', timezone: 'UTC', @@ -15,6 +13,7 @@ export const pgConfig = { max: 10, }, migrations: { + directory: './src/models/db/migrations', tableName: 'migrations', }, seeds: { @@ -22,7 +21,7 @@ export const pgConfig = { }, } -const config: { [key: string]: Knex.Config } = { +const config = { test: pgConfig, development: pgConfig, production: { @@ -34,6 +33,9 @@ const config: { [key: string]: Knex.Config } = { password: process.env.DB_PASSWORD, database: process.env.DB_NAME, }, + migrations: { + directory: './build/models/db/migrations', + }, }, } diff --git a/package-lock.json b/package-lock.json index 4c8d90b1..6c829558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "veritable-ui", - "version": "0.3.25", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veritable-ui", - "version": "0.3.25", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "@digicatapult/tsoa-oauth-express": "^0.1.5", @@ -49,6 +49,7 @@ "chai-jest-snapshot": "^2.0.0", "depcheck": "^1.4.7", "mocha": "^10.4.0", + "pino-colada": "^2.2.2", "prettier": "^3.3.1", "prettier-plugin-organize-imports": "^3.2.4", "sinon": "^18.0.0", @@ -2493,6 +2494,13 @@ "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", "dev": true }, + "node_modules/fast-json-parse": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz", + "integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-redact": { "version": "3.5.0", "license": "MIT", @@ -4176,6 +4184,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-passwd": { "version": "1.0.0", "dev": true, @@ -4388,6 +4406,75 @@ "split2": "^4.0.0" } }, + "node_modules/pino-colada": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/pino-colada/-/pino-colada-2.2.2.tgz", + "integrity": "sha512-tzZl6j4D2v9WSQ4vEa7s8j15v16U6+z6M0fAWJOu5gBKxpy+XnOAgFBzeZGkxm6W3AQ04WvdSpqVLnDBdJlvOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^3.0.0", + "fast-json-parse": "^1.0.2", + "prettier-bytes": "^1.0.3", + "pretty-ms": "^5.0.0", + "split2": "^3.0.0" + }, + "bin": { + "pino-colada": "bin.js" + } + }, + "node_modules/pino-colada/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pino-colada/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pino-colada/node_modules/split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dev": true, + "license": "ISC", + "dependencies": { + "readable-stream": "^3.0.0" + } + }, + "node_modules/pino-colada/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pino-http": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.1.0.tgz", @@ -4525,6 +4612,13 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-bytes": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/prettier-bytes/-/prettier-bytes-1.0.4.tgz", + "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==", + "dev": true, + "license": "ISC" + }, "node_modules/prettier-plugin-organize-imports": { "version": "3.2.4", "dev": true, @@ -4590,6 +4684,22 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/pretty-ms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz", + "integrity": "sha512-4gaK1skD2gwscCfkswYQRmddUb2GJZtzDGRjHWadVHtK/DIKFufa12MvES6/xu1tVbUYeia5bmLcwJtZJQUqnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process": { "version": "0.11.10", "license": "MIT", @@ -5421,6 +5531,13 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "license": "MIT", diff --git a/package.json b/package.json index fb51463d..1b484690 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-ui", - "version": "0.3.25", + "version": "0.4.0", "description": "UI for Veritable", "main": "src/index.ts", "type": "module", @@ -18,12 +18,12 @@ "build": "npm run tsoa:build && tsc", "tsoa:build": "tsoa spec-and-routes", "tsoa:watch": "node --watch-path=./src ./node_modules/.bin/tsoa -- spec-and-routes", - "dev": "npm run tsoa:watch & NODE_ENV=dev node --import=tsimp/import --watch src/index.ts", + "dev": "npm run tsoa:watch & NODE_ENV=dev node --import=tsimp/import --watch src/index.ts | pino-colada", "start": "node build/index.js", "db:cmd": "node --import=tsimp/import ./node_modules/.bin/knex", - "db:migrate": "npm run db:cmd -- migrate:latest --knexfile src/models/db/knexfile.ts", - "db:rollback": "npm run db:cmd -- migrate:rollback --knexfile src/models/db/knexfile.ts", - "db:seed": "npm run db:cmd -- seed:run --knexfile src/models/db/knexfile.ts", + "db:migrate": "npm run db:cmd -- migrate:latest", + "db:rollback": "npm run db:cmd -- migrate:rollback", + "db:seed": "npm run db:cmd -- seed:run", "lint": "prettier -c ./src ./test ./seeds", "lint:fix": "npm run lint -- -w", "xss-scan": "xss-scan" @@ -70,6 +70,7 @@ "depcheck": "^1.4.7", "mocha": "^10.4.0", "prettier": "^3.3.1", + "pino-colada": "^2.2.2", "prettier-plugin-organize-imports": "^3.2.4", "sinon": "^18.0.0", "supertest": "^7.0.0", diff --git a/public/fonts/RobotoMono-Regular.woff2 b/public/fonts/RobotoMono-Regular.woff2 new file mode 100644 index 00000000..53d081f3 Binary files /dev/null and b/public/fonts/RobotoMono-Regular.woff2 differ diff --git a/public/images/heart.svg b/public/images/heart.svg new file mode 100644 index 00000000..b1808062 --- /dev/null +++ b/public/images/heart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/info-accent.svg b/public/images/info-accent.svg new file mode 100644 index 00000000..17f717e4 --- /dev/null +++ b/public/images/info-accent.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/styles/main.css b/public/styles/main.css index 1dfed9bc..2b88f7f2 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -8,6 +8,14 @@ font-style: normal; } +@font-face { + font-family: 'RobotoMono'; + src: + local('Roboto'), + url('/public/fonts/RobotoMono-Regular.woff2') format('woff2'); + font-style: normal; +} + :root { /* color pallete */ --text-color: #45494c; @@ -16,6 +24,11 @@ --bg-color: #f4f5fb; --secondary-color: #ffcc91; --accent-color: #5670f1; + --border-black: #CFD3D4; + + --neutral-accent: var(--accent-color); + --negative-accent: #f57e77; + --positive-accent: #32936f; /* layout and other vars */ --mobile-header-bg: transparent; @@ -95,6 +108,7 @@ a.connections-table.icon { padding: 0.5rem; } .sub-header-bold { + display: block; text-align: left; color: var(--text-color); font-weight: bold; @@ -138,21 +152,28 @@ a.connections-table.icon { /* TODO: include in the media @fn below (mobile) */ .button { display: flex; + gap: 10px; align-items: center; - background-color: var(--accent-color); - color: inherit; + justify-content: center; font-size: 0.75rem; - padding: 5px 10px; + padding: 10px 20px; border-radius: 7px; transition: all 0.3s; - border: none; - + border: 1px solid var(--accent-color); + color: var(--accent-color); + background-color: transparent; + &:hover { opacity: 0.7; } } -.button.icon { +.button-filled { + background-color: var(--accent-color); + color: #fff; +} + +.button-icon { width: 14px; height: 14px; background-repeat: no-repeat; @@ -161,10 +182,9 @@ a.connections-table.icon { background-image: url('/public/images/plus.svg'); } -.button.text { +.button-text { background-color: transparent; vertical-align: middle; - color: #fff; } .main.connections { @@ -176,9 +196,17 @@ a.connections-table.icon { .connections.header { display: flex; flex-direction: row; - justify-content: space-between; color: var(--text-color); font-size: 1rem; + gap: 2ch; +} + +.connections.header > .button { + flex-basis: 28ch; +} + +.connections.header > *:first-child, .connections-list-nav > *:first-child { + margin-right: auto; } .connections.header { @@ -223,13 +251,53 @@ a.connections-table.icon { .card-body { background-color: #fff; overflow-x: auto; - max-width: 60%; + max-width: 1000px; border-radius: 12px; - padding: 1rem; + padding: 2rem 2rem; margin: 1rem 2rem 0rem 1rem; } -/* For mobile view: TODO if more needed - new file */ +.spinner { + position: absolute; + left: 0px; + top: 0px; + height: 100%; + width: 100%; +} + +.url-separator { + padding: 0 0.5rem; + font-size: 1rem; +} + +.search-window { + border-color: var(--text-color-sub); + border-radius: 5px; + border-width: 1px; + padding: 10px; + background-image: url('../images/search.svg'); + background-repeat: no-repeat; + background-position: 10px; + padding-left: 40px; + font-size: 12px; + min-width: 100px; +} + +.accented-container { + position: relative; +} + +.accented-container::after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + height: 10px; + width: 12px; + background-image: url('../images/info-accent.svg'); +} + +/* Mobile view */ @media (max-width: 767px) { body.flex-page { flex-direction: column; @@ -259,139 +327,13 @@ a.connections-table.icon { .side-bar.logo-container { margin: 0; } - .flex-form-content { - flex-direction: column; - } + .card-body { max-width: 100%; margin: 1rem; } - .centered { - align-items: start; - justify-content: start; - } + .search-window { margin-left: 10px; } } - -.spinner { - position: absolute; - left: 0px; - top: 0px; - height: 100%; - width: 100%; -} - -.url-separator { - padding: 0 0.5rem; - font-size: 1rem; -} - -.new-connection-input-field { - min-width: 200px; - max-width: 230px; - padding: 10px; - border-radius: 7px; - margin: 10px; -} -.align-horizontally { - display: flex; - justify-content: space-evenly; - align-items: center; -} -.align-company-information { - display: flex; - justify-content: space-evenly; - align-items: center; - padding: 1rem; - border: 2px solid green; - border-radius: 7px; -} -.align-buttons-left { - display: flex; - min-width: 200px; - max-width: 40%; - align-items: left; - padding-left: 10px; -} -.align-buttons-left button { - margin-left: 10px; -} -.direction-column { - display: flex; - flex-direction: column; -} -.flex-form-content { - display: flex; - justify-content: left; - align-items: left; -} -#text-box-target { - border: 40px var(--text-color); - - border-radius: 12px; - padding: 10px 50px 10px 20px; -} -.centered { - display: flex; - justify-content: center; - align-items: center; - text-align: center; -} -.progress-container { - display: flex; - align-items: center; - width: 90%; - margin: 20px auto; -} - -.progress-bar { - flex: 1; - height: 5px; - background-color: #ddd; - position: relative; - margin-right: 10px; -} - -.progress-bar::before { - content: ''; - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - background-color: var(--accent-color); - transform-origin: left; - transition: transform 0.3s ease; -} - -.bar-1-3::before { - transform: scaleX(0.3333); /* 1/3 of the bar */ -} - -.bar-2-3::before { - transform: scaleX(0.6666); /* 2/3 of the bar */ -} - -.bar-3-3::before { - transform: scaleX(1); /* Full width of the bar */ -} - -.progress-text { - font-size: 12px; - color: var(--text-color-secondary); -} - -.search-window { - border-color: var(--text-color-sub); - border-radius: 5px; - border-width: 1px; - padding: 10px; - background-image: url('../images/search.svg'); - background-repeat: no-repeat; - background-position: 10px; - padding-left: 40px; - font-size: 12px; - min-width: 100px; -} diff --git a/public/styles/new-invite.css b/public/styles/new-invite.css new file mode 100644 index 00000000..0be9876d --- /dev/null +++ b/public/styles/new-invite.css @@ -0,0 +1,187 @@ +#new-invite-form { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: min-content min-content 1fr auto; + gap: 8px 16px; + min-height: 340px; + + --form-element-vert-spacing: 2rem; +} + +#new-invite-form > * { + grid-column: 1/1; + height: min-content; +} + +#new-connection-feedback { + grid-column: 2/2; + grid-row: 1/-2; +} + +#new-connection-feedback { + display: flex; + position: relative; + justify-content: space-evenly; + padding: 1rem; + border: 2px solid var(--feedback-accent); + border-radius: 7px; + line-height: normal; + gap: 6px; + width: 100%; + max-width: 40ch; + min-height: 10rem; + + --feedback-annotation-text-height: 1.5rem; + margin-top: var(--feedback-annotation-text-height); + margin-inline: auto; + + transition: border-color 0.5s; + --feedback-accent: var(--neutral-accent); +} + +#new-connection-feedback.feedback-negative { + --feedback-accent: var(--negative-accent); +} + +#new-connection-feedback.feedback-positive { + --feedback-accent: var(--positive-accent); +} + +#new-connection-feedback::before { + content: 'Official Companies House Information'; + position: absolute; + top: 0; + left: 0; + transform: translateY(-1.5rem); + font-size: 0.75rem; + + transition: color 0.5s; + color: var(--feedback-accent); +} + +#new-connection-feedback > div { + display: flex; + flex-direction: column; + justify-content: space-around; +} + +#new-invite-actions { + grid-row: -1/-1; + display: flex; + justify-content: space-evenly; + width: 100%; +} + +#new-invite-actions > * { + width: 18ch; +} + +.new-connection-input-field { + border: 1px solid var(--border-black); + box-shadow: none; + width: 100%; + min-width: 32ch; + max-width: 40ch; + padding: 1em; + border-radius: 0.5em; + margin-top: var(--form-element-vert-spacing); +} + +.address-line { + display: inline-block; +} + +#new-invite-confirmation-text { + font-size: 0.875rem; + margin-inline: 40px; + text-align: center; +} + +#new-invite-progress { + position: relative; + display: grid; + gap: 6px; + align-items: center; + width: calc(100% - 20px); + margin-top: 1.5rem; +} +#new-invite-progress-bar { + height: 6px; + background-color: #e2e6f9; + position: relative; +} +#new-invite-progress-bar::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 12px; + width: 100%; + border-radius: calc(20px / var(--progress-percent, 1)) / 20px; + background-color: var(--accent-color); + transform-origin: left; + transition: transform 0.3s ease; + transform: scaleX(var(--progress-percent, 0)) translateY(-25%); +} +#new-invite-progress-text { + font-size: 0.75rem; + position: absolute; + right: 0; + top: 0; + transform: translateY(-1.5rem); + color: var(--text-color-secondary); +} +#new-invite-progress-text span:first-child { + color: var(--accent-color); +} + +#from-invite-invite-input { + width: 100%; + max-width: 50ch; + margin-top: var(--form-element-vert-spacing); +} + +#from-invite-invite-input > textarea { + font-family: RobotoMono, monospace; + font-size: 0.8em; + word-break: break-all; + + box-shadow: none; + border: 1px solid var(--border-black); + border-radius: 7px; + + width: 100%; + height: 15rem; + resize: none; + + padding: 2.5ch 2ch 1rem calc(4ch + 24px); + + position: relative; +} +#from-invite-invite-input::after { + content: ''; + position: absolute; + width: 24px; + height: 24px; + left: 2ch; + top: 2ch; + background-image: url('../images/heart.svg'); +} + +/* Mobile view */ +@media (max-width: 767px) { + #new-invite-form { + display: flex; + align-items: center; + flex-direction: column; + --form-element-vert-spacing: 1rem; + } + + #new-connection-feedback { + margin-top: calc(var(--feedback-annotation-text-height) + var(--form-element-vert-spacing)); + } + + #new-invite-actions { + margin-top: var(--form-element-vert-spacing); + } +} \ No newline at end of file diff --git a/public/styles/reset.css b/public/styles/reset.css index 137248dd..db4bf418 100644 --- a/public/styles/reset.css +++ b/public/styles/reset.css @@ -4,13 +4,19 @@ a, a:link, a:visited { text-decoration: none; - color: #c9cace; - + &:hover { filter: brightness(115%); } } + input, button { + font-family: inherit; + color: inherit; + font-size: inherit; + font-weight: inherit; + } + .active { background-color: #5670f1 !important; } diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 02c11f0a..7f67475b 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -10,10 +10,6 @@ import { Logger, type ILogger } from '../logger.js' import IDPService from '../models/idpService.js' import { HTMLController } from './HTMLController.js' -function base64URLEncode(buf: Buffer) { - return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') -} - const nonceCookieOpts: express.CookieOptions = { sameSite: true, httpOnly: true, @@ -61,7 +57,7 @@ export class AuthController extends HTMLController { } // make random state - const nonce = base64URLEncode(randomBytes(32)) + const nonce = randomBytes(32).toString('base64url') res.cookie('VERITABLE_NONCE', nonce, nonceCookieOpts) // setup for final redirect diff --git a/src/controllers/connection/__tests__/fixtures.ts b/src/controllers/connection/__tests__/fixtures.ts index 768bb465..52a77ae7 100644 --- a/src/controllers/connection/__tests__/fixtures.ts +++ b/src/controllers/connection/__tests__/fixtures.ts @@ -1,4 +1,5 @@ export const notFoundCompanyNumber = '00000000' +export const invalidCompanyNumber = 'XXXXXXXX' export const validCompanyNumber = '00000001' export const validExistingCompanyNumber = '00000002' export const validCompanyNumberInDispute = '00000003' @@ -48,3 +49,22 @@ export const validCompanyMap: Record = { [validCompanyNumberInDispute]: validCompanyInDispute, [validCompanyNumberInactive]: validCompanyInactive, } + +const buildBase64Invite = (companyNumber: string) => + Buffer.from( + JSON.stringify({ + companyNumber, + inviteUrl: 'http://example.com', + }), + 'utf8' + ).toString('base64url') + +export const invalidBase64Invite = '!@£$%^&*()' +export const invalidInvite = Buffer.from(JSON.stringify({}), 'utf8').toString('base64url') + +export const invalidCompanyNumberInvite = buildBase64Invite(invalidCompanyNumber) +export const notFoundCompanyNumberInvite = buildBase64Invite(notFoundCompanyNumber) +export const validExistingCompanyNumberInvite = buildBase64Invite(validExistingCompanyNumber) +export const validCompanyNumberInDisputeInvite = buildBase64Invite(validCompanyNumberInDispute) +export const validCompanyNumberInactiveInvite = buildBase64Invite(validCompanyNumberInactive) +export const validCompanyNumberInvite = buildBase64Invite(validCompanyNumber) diff --git a/src/controllers/connection/__tests__/helpers.ts b/src/controllers/connection/__tests__/helpers.ts index 3fdefad9..09965e11 100644 --- a/src/controllers/connection/__tests__/helpers.ts +++ b/src/controllers/connection/__tests__/helpers.ts @@ -8,7 +8,9 @@ import { ConnectionRow } from '../../../models/db/types.js' import EmailService from '../../../models/emailService/index.js' import VeritableCloudagent from '../../../models/veritableCloudagent.js' import ConnectionTemplates from '../../../views/connection.js' -import NewConnectionTemplates, { CompanyProfileText, FormStage } from '../../../views/newConnection.js' +import { FormFeedback } from '../../../views/newConnection/base.js' +import { FromInviteTemplates } from '../../../views/newConnection/fromInvite.js' +import { NewInviteTemplates } from '../../../views/newConnection/newInvite.js' import { notFoundCompanyNumber, validCompanyMap, validCompanyNumber, validExistingCompanyNumber } from './fixtures.js' function templateFake(templateName: string, ...args: any[]) { @@ -73,42 +75,78 @@ export const withNewConnectionMocks = () => { invitationUrl: `url-${companyName}`, } }, + receiveOutOfBandInvite: (params: { companyName: string; invitationUrl: string }) => { + return { + outOfBandRecord: { + id: 'oob-record', + }, + connectionRecord: { + id: 'oob-connection', + }, + } + }, } as unknown as VeritableCloudagent const mockEmail = { sendMail: () => {}, } as unknown as EmailService - const mockNewConnection = { - formPage: (targetBox: CompanyProfileText, formStage: FormStage) => - templateFake('formPage', targetBox.status, formStage), - companyFormInput: ({ targetBox, formStage, email, companyNumber }: any) => + const mockNewInvite = { + newInviteFormPage: (feedback: FormFeedback) => templateFake('newInvitePage', feedback.type), + newInviteForm: ({ feedback, formStage, email, companyNumber }: any) => templateFake( 'companyFormInput', - targetBox.status, - targetBox.company?.company_name || '', - targetBox.errorMessage || '', + feedback.type, + feedback.company?.company_name || '', + feedback.message || feedback.error || '', formStage, email, companyNumber ), - } as unknown as NewConnectionTemplates + } as unknown as NewInviteTemplates + const mockFromInvite = { + fromInviteFormPage: (feedback: FormFeedback) => templateFake('fromInvitePage', feedback.type), + fromInviteForm: ({ feedback, formStage }: any) => + templateFake( + 'fromInviteForm', + feedback.type, + feedback.company?.company_name || '', + feedback.message || feedback.error || '', + formStage + ), + } as unknown as FromInviteTemplates + const mockEnv = { get: (name: string) => { - if (name === 'INVITATION_PIN_SECRET') { - return 'secret' + switch (name) { + case 'INVITATION_PIN_SECRET': + return 'secret' + case 'INVITATION_FROM_COMPANY_NUMBER': + return '07964699' + default: + throw new Error() } - throw new Error() }, } as unknown as Env return { - mockLogger, mockTransactionDb, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, - mockNewConnection, + mockNewInvite, + mockFromInvite, mockEnv, + mockLogger, + args: [ + mockDb, + mockCompanyHouseEntity, + mockCloudagent, + mockEmail, + mockNewInvite, + mockFromInvite, + mockEnv, + mockLogger, + ] as const, } } diff --git a/src/controllers/connection/__tests__/newConnection.test.ts b/src/controllers/connection/__tests__/newConnection.test.ts index ea8bcc97..ea10d62d 100644 --- a/src/controllers/connection/__tests__/newConnection.test.ts +++ b/src/controllers/connection/__tests__/newConnection.test.ts @@ -6,11 +6,20 @@ import { toHTMLString, withNewConnectionMocks } from './helpers.js' import { NewConnectionController } from '../newConnection.js' import { + invalidBase64Invite, + invalidCompanyNumber, + invalidCompanyNumberInvite, + invalidInvite, notFoundCompanyNumber, + notFoundCompanyNumberInvite, validCompanyNumber, validCompanyNumberInDispute, + validCompanyNumberInDisputeInvite, validCompanyNumberInactive, + validCompanyNumberInactiveInvite, + validCompanyNumberInvite, validExistingCompanyNumber, + validExistingCompanyNumberInvite, } from './fixtures.js' describe('NewConnectionController', () => { @@ -19,52 +28,39 @@ describe('NewConnectionController', () => { }) describe('newConnectionForm', () => { - it('should return rendered form template', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + it('should return rendered form template (fromInvite = false)', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller.newConnectionForm().then(toHTMLString) - expect(result).to.equal('formPage_error-form_formPage') + expect(result).to.equal('newInvitePage_message_newInvitePage') + }) + + it('should return rendered form template (fromInvite = true)', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.newConnectionForm(true).then(toHTMLString) + expect(result).to.equal('fromInvitePage_message_fromInvitePage') }) }) describe('verifyCompanyForm', () => { + it('should return form page when company number invalid', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyCompanyForm(invalidCompanyNumber).then(toHTMLString) + expect(result).to.equal('newInvitePage_message_newInvitePage') + }) + it('should return rendered error when company not found', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller.verifyCompanyForm(notFoundCompanyNumber).then(toHTMLString) expect(result).to.equal('companyFormInput_error--Company number does not exist-form--00000000_companyFormInput') }) it('should return rendered error when company already connected', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller.verifyCompanyForm(validExistingCompanyNumber).then(toHTMLString) expect(result).to.equal( 'companyFormInput_error--Connection already exists with NAME2-form--00000002_companyFormInput' @@ -72,17 +68,8 @@ describe('NewConnectionController', () => { }) it('should return rendered error when company registered office in dispute', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller.verifyCompanyForm(validCompanyNumberInDispute).then(toHTMLString) expect(result).to.equal( 'companyFormInput_error--Cannot validate company NAME3 as address is currently in dispute-form--00000003_companyFormInput' @@ -90,56 +77,96 @@ describe('NewConnectionController', () => { }) it('should return rendered error when company not active', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller.verifyCompanyForm(validCompanyNumberInactive).then(toHTMLString) expect(result).to.equal('companyFormInput_error--Company NAME4 is not active-form--00000004_companyFormInput') }) it('should return success form', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller.verifyCompanyForm(validCompanyNumber).then(toHTMLString) - expect(result).to.equal('companyFormInput_success-NAME--form--00000001_companyFormInput') + expect(result).to.equal('companyFormInput_companyFound-NAME--form--00000001_companyFormInput') }) }) - describe('submitCompanyNumber', () => { + describe('verifyInviteForm', () => { + it('should rendered page when invite is empty', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm('').then(toHTMLString) + expect(result).to.equal('fromInvitePage_message_fromInvitePage') + }) + + it('should rendered error when invite invalid base64', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(invalidBase64Invite).then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + }) + + it('should rendered error when invite invalid format', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(invalidInvite).then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + }) + + it('should rendered error when company number invalid', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(invalidCompanyNumberInvite).then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + }) + it('should return rendered error when company not found', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(notFoundCompanyNumberInvite).then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Company number does not exist-invite_fromInviteForm') + }) + + it('should return rendered error when company already connected', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(validExistingCompanyNumberInvite).then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME2-invite_fromInviteForm') + }) + + it('should return rendered error when company registered office in dispute', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(validCompanyNumberInDisputeInvite).then(toHTMLString) + expect(result).to.equal( + 'fromInviteForm_error--Cannot validate company NAME3 as address is currently in dispute-invite_fromInviteForm' ) + }) + + it('should return rendered error when company not active', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(validCompanyNumberInactiveInvite).then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Company NAME4 is not active-invite_fromInviteForm') + }) + + it('should return success form', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.verifyInviteForm(validCompanyNumberInvite).then(toHTMLString) + expect(result).to.equal('fromInviteForm_companyFound-NAME--invite_fromInviteForm') + }) + }) + + describe('submitNewInvite', () => { + it('should return rendered error when company not found', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: notFoundCompanyNumber, email: 'alice@example.com', - submitButton: 'Continue', + action: 'continue', }) .then(toHTMLString) expect(result).to.equal( @@ -148,22 +175,13 @@ describe('NewConnectionController', () => { }) it('should return rendered error when company already connected', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: validExistingCompanyNumber, email: 'alice@example.com', - submitButton: 'Continue', + action: 'continue', }) .then(toHTMLString) expect(result).to.equal( @@ -172,22 +190,13 @@ describe('NewConnectionController', () => { }) it('should return rendered error when company registered office in dispute', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: validCompanyNumberInDispute, email: 'alice@example.com', - submitButton: 'Continue', + action: 'continue', }) .then(toHTMLString) expect(result).to.equal( @@ -196,22 +205,13 @@ describe('NewConnectionController', () => { }) it('should return rendered error when company not active', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: validCompanyNumberInactive, email: 'alice@example.com', - submitButton: 'Continue', + action: 'continue', }) .then(toHTMLString) expect(result).to.equal( @@ -220,57 +220,33 @@ describe('NewConnectionController', () => { }) it('should return confirmation form if button is not Submit', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) const result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: validCompanyNumber, email: 'alice@example.com', - submitButton: 'Continue', + action: 'continue', }) .then(toHTMLString) - expect(result).to.equal('companyFormInput_success-NAME--confirmation-alice@example.com-00000001_companyFormInput') + expect(result).to.equal( + 'companyFormInput_companyFound-NAME--confirmation-alice@example.com-00000001_companyFormInput' + ) }) it('should return rendered error when unique constraint is violated', async () => { - let { - mockLogger, - mockTransactionDb, - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - } = withNewConnectionMocks() + let { mockTransactionDb, args } = withNewConnectionMocks() sinon .stub(mockTransactionDb, 'insert') .rejects(new Error('details - duplicate key value violates unique constraint "unq_connection_company_number"')) - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + const controller = new NewConnectionController(...args) const result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: validCompanyNumber, email: 'alice@example.com', - submitButton: 'Submit', + action: 'submit', }) .then(toHTMLString) @@ -280,29 +256,20 @@ describe('NewConnectionController', () => { }) it('should return success even if email send fails', async () => { - let { mockLogger, mockDb, mockCompanyHouseEntity, mockCloudagent, mockEmail, mockNewConnection, mockEnv } = - withNewConnectionMocks() + let { args, mockEmail } = withNewConnectionMocks() sinon.stub(mockEmail, 'sendMail').rejects(new Error()) - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + const controller = new NewConnectionController(...args) const result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: validCompanyNumber, email: 'alice@example.com', - submitButton: 'Submit', + action: 'submit', }) .then(toHTMLString) - expect(result).to.equal('companyFormInput_success-NAME--success-alice@example.com-00000001_companyFormInput') + expect(result).to.equal('companyFormInput_companyFound-NAME--success-alice@example.com-00000001_companyFormInput') }) describe('happy path assertions', function () { @@ -312,35 +279,18 @@ describe('NewConnectionController', () => { let result: string beforeEach(async () => { - let { - mockLogger, - mockTransactionDb, - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - } = withNewConnectionMocks() + let { mockTransactionDb, mockEmail, args } = withNewConnectionMocks() insertSpy = sinon.spy(mockTransactionDb, 'insert') emailSpy = sinon.spy(mockEmail, 'sendMail') clock = sinon.useFakeTimers(100) - const controller = new NewConnectionController( - mockDb, - mockCompanyHouseEntity, - mockCloudagent, - mockEmail, - mockNewConnection, - mockEnv, - mockLogger - ) + const controller = new NewConnectionController(...args) result = await controller - .submitCompanyNumber({ + .submitNewInvite({ companyNumber: validCompanyNumber, email: 'alice@example.com', - submitButton: 'Submit', + action: 'submit', }) .then(toHTMLString) }) @@ -350,7 +300,9 @@ describe('NewConnectionController', () => { }) it('should return success form', () => { - expect(result).to.equal('companyFormInput_success-NAME--success-alice@example.com-00000001_companyFormInput') + expect(result).to.equal( + 'companyFormInput_companyFound-NAME--success-alice@example.com-00000001_companyFormInput' + ) }) it('should insert two row', () => { @@ -364,17 +316,7 @@ describe('NewConnectionController', () => { company_name: 'NAME', company_number: '00000001', status: 'pending', - }, - ]) - }) - - it('should insert correct value into connection table', () => { - expect(insertSpy.firstCall.args).deep.equal([ - 'connection', - { - company_name: 'NAME', - company_number: '00000001', - status: 'pending', + agent_connection_id: null, }, ]) }) @@ -395,9 +337,14 @@ describe('NewConnectionController', () => { }) it('should send first email to recipient', () => { + const expectedInvite = { companyNumber: '07964699', inviteUrl: 'url-NAME' } + const expectedInviteBase64 = Buffer.from(JSON.stringify(expectedInvite), 'utf8').toString('base64url') expect(emailSpy.firstCall.args).deep.equal([ 'connection_invite', - { to: 'alice@example.com', invite: 'url-NAME' }, + { + to: 'alice@example.com', + invite: expectedInviteBase64, + }, ]) }) @@ -410,4 +357,182 @@ describe('NewConnectionController', () => { }) }) }) + + describe('submitFromInvite', () => { + it('should rendered error when invite is empty', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller.submitFromInvite({ invite: '', action: 'createConnection' }).then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + }) + + it('should rendered error when invite invalid base64', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ invite: invalidBase64Invite, action: 'createConnection' }) + .then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + }) + + it('should rendered error when invite invalid format', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ invite: invalidInvite, action: 'createConnection' }) + .then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + }) + + it('should rendered error when company number invalid', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ invite: invalidCompanyNumberInvite, action: 'createConnection' }) + .then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + }) + + it('should return rendered error when company not found', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ invite: notFoundCompanyNumberInvite, action: 'createConnection' }) + .then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Company number does not exist-invite_fromInviteForm') + }) + + it('should return rendered error when company already connected', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ invite: validExistingCompanyNumberInvite, action: 'createConnection' }) + .then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME2-invite_fromInviteForm') + }) + + it('should return rendered error when company registered office in dispute', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ invite: validCompanyNumberInDisputeInvite, action: 'createConnection' }) + .then(toHTMLString) + expect(result).to.equal( + 'fromInviteForm_error--Cannot validate company NAME3 as address is currently in dispute-invite_fromInviteForm' + ) + }) + + it('should return rendered error when company not active', async () => { + let { args } = withNewConnectionMocks() + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ invite: validCompanyNumberInactiveInvite, action: 'createConnection' }) + .then(toHTMLString) + expect(result).to.equal('fromInviteForm_error--Company NAME4 is not active-invite_fromInviteForm') + }) + + it('should return rendered error when unique constraint is violated', async () => { + let { mockTransactionDb, args } = withNewConnectionMocks() + + sinon + .stub(mockTransactionDb, 'insert') + .rejects(new Error('details - duplicate key value violates unique constraint "unq_connection_company_number"')) + + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ + invite: validCompanyNumberInvite, + action: 'createConnection', + }) + .then(toHTMLString) + + expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME-invite_fromInviteForm') + }) + + it('should return success even if email send fails', async () => { + let { args, mockEmail } = withNewConnectionMocks() + + sinon.stub(mockEmail, 'sendMail').rejects(new Error()) + + const controller = new NewConnectionController(...args) + const result = await controller + .submitFromInvite({ + invite: validCompanyNumberInvite, + action: 'createConnection', + }) + .then(toHTMLString) + + expect(result).to.equal('fromInviteForm_companyFound-NAME--success_fromInviteForm') + }) + + describe('happy path assertions', function () { + let clock: sinon.SinonFakeTimers + let insertSpy: sinon.SinonSpy + let emailSpy: sinon.SinonSpy + let result: string + + beforeEach(async () => { + let { mockTransactionDb, mockEmail, args } = withNewConnectionMocks() + + insertSpy = sinon.spy(mockTransactionDb, 'insert') + emailSpy = sinon.spy(mockEmail, 'sendMail') + clock = sinon.useFakeTimers(100) + + const controller = new NewConnectionController(...args) + result = await controller + .submitFromInvite({ + invite: validCompanyNumberInvite, + action: 'createConnection', + }) + .then(toHTMLString) + }) + + afterEach(() => { + clock.restore() + }) + + it('should return success form', () => { + expect(result).to.equal('fromInviteForm_companyFound-NAME--success_fromInviteForm') + }) + + it('should insert two row', () => { + expect(insertSpy.callCount).to.equal(2) + }) + + it('should insert correct value into connection table', () => { + expect(insertSpy.firstCall.args).deep.equal([ + 'connection', + { + company_name: 'NAME', + company_number: '00000001', + status: 'pending', + agent_connection_id: 'oob-connection', + }, + ]) + }) + + it('should insert correct value into connection_invite table', () => { + expect(insertSpy.secondCall.args[0]).equal('connection_invite') + const { pin_hash, ...rest } = insertSpy.secondCall.args[1] + expect(rest).deep.equal({ + connection_id: '42', + oob_invite_id: 'oob-record', + expires_at: new Date(100 + 14 * 24 * 60 * 60 * 1000), + }) + expect(typeof pin_hash).to.equal('string') + }) + + it('should send two emails', () => { + expect(emailSpy.callCount).equal(1) + }) + + it('should send email to admin', () => { + expect(emailSpy.firstCall.args[0]).equal('connection_invite_admin') + expect(emailSpy.firstCall.args[1]?.address).equal( + 'NAME\r\nADDRESS_LINE_1\r\nADDRESS_LINE_2\r\nCARE_OF\r\nLOCALITY\r\nPO_BOX\r\nPOSTAL_CODE\r\nCOUNTRY\r\nPREMISES\r\nREGION' + ) + expect(emailSpy.firstCall.args[1]?.pin).match(/[0-9]{6}/) + }) + }) + }) }) diff --git a/src/controllers/connection/newConnection.ts b/src/controllers/connection/newConnection.ts index 7c425897..6e18da7e 100644 --- a/src/controllers/connection/newConnection.ts +++ b/src/controllers/connection/newConnection.ts @@ -3,23 +3,37 @@ import { randomInt } from 'node:crypto' import argon2 from 'argon2' import { Body, Get, Post, Produces, Query, Route, Security, SuccessResponse } from 'tsoa' import { inject, injectable, singleton } from 'tsyringe' +import { z } from 'zod' import { Env } from '../../env.js' import { Logger, type ILogger } from '../../logger.js' import CompanyHouseEntity, { CompanyProfile } from '../../models/companyHouseEntity.js' import Database from '../../models/db/index.js' import EmailService from '../../models/emailService/index.js' -import type { COMPANY_NUMBER, EMAIL } from '../../models/strings.js' +import { + BASE_64_URL, + base64UrlRegex, + companyNumberRegex, + type COMPANY_NUMBER, + type EMAIL, +} from '../../models/strings.js' import VeritableCloudagent from '../../models/veritableCloudagent.js' -import NewConnectionTemplates, { FormStage } from '../../views/newConnection.js' +import { FromInviteTemplates } from '../../views/newConnection/fromInvite.js' +import { NewInviteFormStage, NewInviteTemplates } from '../../views/newConnection/newInvite.js' import { HTML, HTMLController } from '../HTMLController.js' const submitToFormStage = { - Back: 'form', - Continue: 'confirmation', - Submit: 'success', + back: 'form', + continue: 'confirmation', + submit: 'success', } as const +const inviteParser = z.object({ + companyNumber: z.string(), + inviteUrl: z.string(), +}) +type Invite = z.infer + @singleton() @injectable() @Security('oauth2') @@ -31,7 +45,8 @@ export class NewConnectionController extends HTMLController { private companyHouseEntity: CompanyHouseEntity, private cloudagent: VeritableCloudagent, private email: EmailService, - private newConnection: NewConnectionTemplates, + private newInvite: NewInviteTemplates, + private fromInvite: FromInviteTemplates, private env: Env, @inject(Logger) private logger: ILogger ) { @@ -45,15 +60,21 @@ export class NewConnectionController extends HTMLController { */ @SuccessResponse(200) @Get('/') - public async newConnectionForm(): Promise { - return this.html( - this.newConnection.formPage( - { - status: 'error', - errorMessage: 'Please type in company number to populate information', - }, - 'form' + public async newConnectionForm(@Query() fromInvite: boolean = false): Promise { + if (fromInvite) { + return this.html( + this.fromInvite.fromInviteFormPage({ + type: 'message', + message: 'Please paste the invite text from the invitation email', + }) ) + } + + return this.html( + this.newInvite.newInviteFormPage({ + type: 'message', + message: 'Please type in a valid company number to populate information', + }) ) } @@ -62,17 +83,21 @@ export class NewConnectionController extends HTMLController { */ @SuccessResponse(200) @Get('/verify-company') - public async verifyCompanyForm(@Query() companyNumber: COMPANY_NUMBER): Promise { + public async verifyCompanyForm(@Query() companyNumber: COMPANY_NUMBER | string): Promise { + if (!companyNumber.match(companyNumberRegex)) { + return this.newConnectionForm() + } + const companyOrError = await this.lookupCompany(companyNumber) if (companyOrError.type === 'error') { - return companyOrError.response + return this.newInviteErrorHtml(companyOrError.message, undefined, companyNumber) } const company = companyOrError.company return this.html( - this.newConnection.companyFormInput({ - targetBox: { - status: 'success', + this.newInvite.newInviteForm({ + feedback: { + type: 'companyFound', company: company, }, formStage: 'form', @@ -81,30 +106,61 @@ export class NewConnectionController extends HTMLController { ) } + /** + * @returns a company from a validated connection invitation + */ + @SuccessResponse(200) + @Get('/verify-invite') + public async verifyInviteForm(@Query() invite: BASE_64_URL | string): Promise { + if (invite === '') { + return this.newConnectionForm(true) + } + + if (!invite.match(base64UrlRegex)) { + return this.receiveInviteErrorHtml('Invitation is not valid') + } + + const inviteOrError = await this.decodeInvite(invite) + if (inviteOrError.type === 'error') { + return this.receiveInviteErrorHtml(inviteOrError.message) + } + const company = inviteOrError.company + + return this.html( + this.fromInvite.fromInviteForm({ + feedback: { + type: 'companyFound', + company: company, + }, + formStage: 'invite', + }) + ) + } + /** * submits the company number for */ @SuccessResponse(200) - @Post('/submit') - public async submitCompanyNumber( + @Post('/create-invitation') + public async submitNewInvite( @Body() body: { - companyNumber: string + companyNumber: COMPANY_NUMBER email: EMAIL - submitButton: 'Back' | 'Continue' | 'Submit' + action: 'back' | 'continue' | 'submit' } ): Promise { // lookup company by number - const companyOrError = await this.lookupCompany(body.companyNumber, body.email) + const companyOrError = await this.lookupCompany(body.companyNumber) if (companyOrError.type === 'error') { - return companyOrError.response + return this.newInviteErrorHtml(companyOrError.message, body.email, body.companyNumber) } const company = companyOrError.company // if we're not at the final submission return next stage - const formStage: FormStage = submitToFormStage[body.submitButton] + const formStage: NewInviteFormStage = submitToFormStage[body.action] if (formStage !== 'success') { - return this.formSuccessHtml(formStage, company, body.email) + return this.newInviteSuccessHtml(formStage, company, body.email) } this.logger.debug('NEW_CONNECTION: details %s (%s)', company.company_name, company.company_number) @@ -117,30 +173,112 @@ export class NewConnectionController extends HTMLController { ]) // insert the connection - const dbResult = await this.insertNewConnection(company, body.email, pinHash, invite.invitation.id) + const dbResult = await this.insertNewConnection(company, pinHash, invite.invitation.id, null) if (dbResult.type === 'error') { - return dbResult.response + return this.newInviteErrorHtml(dbResult.error, body.email, company.company_number) + } + + const wrappedInvitation: Invite = { + companyNumber: this.env.get('INVITATION_FROM_COMPANY_NUMBER'), + inviteUrl: invite.invitationUrl, } // send emails - await this.sendNewConnectionEmails(company, body.email, invite.invitationUrl, pin) + await this.sendNewConnectionEmail(body.email, wrappedInvitation) + await this.sendAdminEmail(company, pin) // return the success response - this.logger.debug('NEW_CONNECTION: complete', invite.invitation.id) - return this.formSuccessHtml(formStage, company, body.email) + this.logger.debug('NEW_CONNECTION: complete: %s', dbResult.connectionId) + return this.newInviteSuccessHtml(formStage, company, body.email) + } + + /** + * submits the company number for + */ + @SuccessResponse(200) + @Post('/receive-invitation') + public async submitFromInvite( + @Body() + body: { + invite: BASE_64_URL + action: 'createConnection' + } + ): Promise { + if (!body.invite.match(base64UrlRegex)) { + return this.receiveInviteErrorHtml('Invitation is not valid') + } + + const inviteOrError = await this.decodeInvite(body.invite) + if (inviteOrError.type === 'error') { + return this.receiveInviteErrorHtml(inviteOrError.message) + } + + this.logger.debug( + 'NEW_CONNECTION: details %s (%s)', + inviteOrError.company.company_name, + inviteOrError.company.company_number + ) + + // otherwise we're doing final submit. Generate pin and oob invitation + const pin = randomInt(1e6).toString(10).padStart(6, '0') + const [pinHash, invite] = await Promise.all([ + argon2.hash(pin, { secret: Buffer.from(this.env.get('INVITATION_PIN_SECRET'), 'utf8') }), + this.cloudagent.receiveOutOfBandInvite({ + companyName: inviteOrError.company.company_name, + invitationUrl: inviteOrError.inviteUrl, + }), + ]) + + const dbResult = await this.insertNewConnection( + inviteOrError.company, + pinHash, + invite.outOfBandRecord.id, + invite.connectionRecord.id + ) + if (dbResult.type === 'error') { + return this.receiveInviteErrorHtml(dbResult.error) + } + + await this.sendAdminEmail(inviteOrError.company, pin) + + this.logger.debug('NEW_CONNECTION: complete: %s', dbResult.connectionId) + return this.receiveInviteSuccessHtml(inviteOrError.company) + } + + private async decodeInvite( + invite: string + ): Promise<{ type: 'success'; inviteUrl: string; company: CompanyProfile } | { type: 'error'; message: string }> { + let wrappedInvite: Invite + try { + wrappedInvite = inviteParser.parse(JSON.parse(Buffer.from(invite, 'base64url').toString('utf8'))) + } catch (_) { + return { + type: 'error', + message: 'Invitation is not valid', + } + } + + if (!wrappedInvite.companyNumber.match(companyNumberRegex)) { + return { type: 'error', message: 'Invitation is not valid' } + } + + const companyOrError = await this.lookupCompany(wrappedInvite.companyNumber) + if (companyOrError.type === 'error') { + return companyOrError + } + return { type: 'success', inviteUrl: wrappedInvite.inviteUrl, company: companyOrError.company } } private async lookupCompany( - companyNumber: string, - email?: string - ): Promise<{ type: 'success'; company: CompanyProfile } | { type: 'error'; response: Promise }> { + companyNumber: COMPANY_NUMBER + ): Promise<{ type: 'success'; company: CompanyProfile } | { type: 'error'; message: string }> { this.logger.debug('COMPANY_LOOKUP: %s', companyNumber) const companySearch = await this.companyHouseEntity.getCompanyProfileByCompanyNumber(companyNumber) if (companySearch.type === 'notFound') { return { type: 'error', - response: this.formErrorHtml('Company number does not exist', email, companyNumber), + message: 'Company number does not exist', } } const company = companySearch.company @@ -149,25 +287,21 @@ export class NewConnectionController extends HTMLController { if (existingConnections.length !== 0) { return { type: 'error', - response: this.formErrorHtml(`Connection already exists with ${company.company_name}`, email, companyNumber), + message: `Connection already exists with ${company.company_name}`, } } if (company.registered_office_is_in_dispute) { return { type: 'error', - response: this.formErrorHtml( - `Cannot validate company ${company.company_name} as address is currently in dispute`, - email, - companyNumber - ), + message: `Cannot validate company ${company.company_name} as address is currently in dispute`, } } if (company.company_status !== 'active') { return { type: 'error', - response: this.formErrorHtml(`Company ${company.company_name} is not active`, email, companyNumber), + message: `Company ${company.company_name} is not active`, } } @@ -179,17 +313,19 @@ export class NewConnectionController extends HTMLController { private async insertNewConnection( company: CompanyProfile, - email: string, pinHash: string, - invitationId: string - ): Promise<{ type: 'success' } | { type: 'error'; response: Promise }> { + invitationId: string, + agentConnectionId: string | null + ): Promise<{ type: 'success'; connectionId: string } | { type: 'error'; error: string }> { this.logger.debug('NEW_CONNECTION: invite id %s', invitationId) try { + let connectionId: string = '' await this.db.withTransaction(async (db) => { const [record] = await db.insert('connection', { company_name: company.company_name, company_number: company.company_number, + agent_connection_id: agentConnectionId, status: 'pending', }) @@ -199,9 +335,10 @@ export class NewConnectionController extends HTMLController { pin_hash: pinHash, expires_at: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000), }) + connectionId = record.id }) - return { type: 'success' } + return { type: 'success', connectionId } } catch (err) { if ( err instanceof Error && @@ -209,22 +346,29 @@ export class NewConnectionController extends HTMLController { ) { return { type: 'error', - response: this.formErrorHtml( - `Connection already exists with ${company.company_name}`, - email, - company.company_number - ), + error: `Connection already exists with ${company.company_name}`, } } throw err } } - private async sendNewConnectionEmails(company: CompanyProfile, email: string, inviteUrl: string, pin: string) { + private async sendNewConnectionEmail(email: string, invite: { companyNumber: string; inviteUrl: string }) { + this.logger.debug('NEW_CONNECTION: sending emails') + const inviteBase64 = Buffer.from(JSON.stringify(invite), 'utf8').toString('base64url') + + try { + await this.email.sendMail('connection_invite', { + to: email, + invite: inviteBase64, + }) + } catch (_) {} + } + + private async sendAdminEmail(company: CompanyProfile, pin: string) { this.logger.debug('NEW_CONNECTION: sending emails') try { - await this.email.sendMail('connection_invite', { to: email, invite: inviteUrl }) await this.email.sendMail('connection_invite_admin', { address: [ company.company_name, @@ -245,11 +389,11 @@ export class NewConnectionController extends HTMLController { } catch (_) {} } - private formSuccessHtml(formStage: FormStage, company: CompanyProfile, email: string) { + private newInviteSuccessHtml(formStage: NewInviteFormStage, company: CompanyProfile, email: string) { return this.html( - this.newConnection.companyFormInput({ - targetBox: { - status: 'success', + this.newInvite.newInviteForm({ + feedback: { + type: 'companyFound', company: company, }, formStage: formStage, @@ -259,12 +403,12 @@ export class NewConnectionController extends HTMLController { ) } - private formErrorHtml(message: string, email?: string, companyNumber?: string) { + private newInviteErrorHtml(message: string, email?: string, companyNumber?: string) { return this.html( - this.newConnection.companyFormInput({ - targetBox: { - status: 'error', - errorMessage: message, + this.newInvite.newInviteForm({ + feedback: { + type: 'error', + error: message, }, formStage: 'form', email: email, @@ -272,4 +416,28 @@ export class NewConnectionController extends HTMLController { }) ) } + + private receiveInviteSuccessHtml(company: CompanyProfile) { + return this.html( + this.fromInvite.fromInviteForm({ + feedback: { + type: 'companyFound', + company: company, + }, + formStage: 'success', + }) + ) + } + + private receiveInviteErrorHtml(message: string) { + return this.html( + this.fromInvite.fromInviteForm({ + feedback: { + type: 'error', + error: message, + }, + formStage: 'invite', + }) + ) + } } diff --git a/src/env.ts b/src/env.ts index ae97e505..f08ff993 100644 --- a/src/env.ts +++ b/src/env.ts @@ -56,8 +56,9 @@ const envConfig = { EMAIL_TRANSPORT: envalid.str({ default: 'STREAM', choices: ['STREAM'] }), EMAIL_FROM_ADDRESS: envalid.email({ default: 'hello@veritable.com' }), EMAIL_ADMIN_ADDRESS: envalid.email({ default: 'admin@veritable.com' }), - CLOUDAGENT_ADMIN_ORIGIN: envalid.url({ devDefault: 'http://localhost:3001' }), + CLOUDAGENT_ADMIN_ORIGIN: envalid.url({ devDefault: 'http://localhost:3100' }), INVITATION_PIN_SECRET: envalid.str({ devDefault: 'secret' }), + INVITATION_FROM_COMPANY_NUMBER: envalid.str({ devDefault: '07964699' }), } export type ENV_CONFIG = typeof envConfig diff --git a/src/models/__tests__/fixtures/cloudagentFixtures.ts b/src/models/__tests__/fixtures/cloudagentFixtures.ts index f969818c..2b350447 100644 --- a/src/models/__tests__/fixtures/cloudagentFixtures.ts +++ b/src/models/__tests__/fixtures/cloudagentFixtures.ts @@ -1,20 +1,24 @@ -export const successResponse = { +export const createInviteSuccessResponse = { invitationUrl: 'example.com', invitation: { '@id': 'example-id', }, } -export const successResponseTransformed = { +export const createInviteSuccessResponseTransformed = { invitationUrl: 'example.com', invitation: { id: 'example-id', }, } -export const invalidResponse = { - invitationUrl: 'example.com', - invitation: { - id: 'should be @id', +export const receiveInviteSuccessResponse = { + outOfBandRecord: { + id: 'oob-id', + }, + connectionRecord: { + id: 'connection-id', }, } + +export const invalidResponse = {} diff --git a/src/models/__tests__/fixtures/companyHouseFixtures.ts b/src/models/__tests__/fixtures/companyHouseFixtures.ts index 60dc0867..3193982b 100644 --- a/src/models/__tests__/fixtures/companyHouseFixtures.ts +++ b/src/models/__tests__/fixtures/companyHouseFixtures.ts @@ -1,4 +1,3 @@ -import { CompanyProfileText } from '../../../views/newConnection.js' import { CompanyProfile } from '../../companyHouseEntity.js' export const validCompanyNumber = '07964699' @@ -16,13 +15,3 @@ export const successResponse: CompanyProfile = { company_name: 'DIGITAL CATAPULT', company_number: '07964699', } - -export const testErrorTargetBox: CompanyProfileText = { - status: 'error', - errorMessage: 'This is a test error message', -} - -export const testSuccessTargetBox: CompanyProfileText = { - status: 'success', - company: successResponse, -} diff --git a/src/models/__tests__/helpers/mockCloudagent.ts b/src/models/__tests__/helpers/mockCloudagent.ts index 0a9a7dc1..baa48270 100644 --- a/src/models/__tests__/helpers/mockCloudagent.ts +++ b/src/models/__tests__/helpers/mockCloudagent.ts @@ -2,11 +2,10 @@ import { container } from 'tsyringe' import { Dispatcher, MockAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici' import { Env } from '../../../env.js' -import { successResponse } from '../fixtures/cloudagentFixtures.js' const env = container.resolve(Env) -export function withCloudagentMock(code: number = 200, responseBody: any = successResponse) { +export function withCloudagentMock(path: string, code: number, responseBody: any) { let originalDispatcher: Dispatcher let agent: MockAgent beforeEach(function () { @@ -17,7 +16,7 @@ export function withCloudagentMock(code: number = 200, responseBody: any = succe const client = agent.get(env.get('CLOUDAGENT_ADMIN_ORIGIN')) client .intercept({ - path: `/oob/create-invitation`, + path, method: 'POST', }) .reply(code, responseBody) diff --git a/src/models/__tests__/veritableCloudagent.test.ts b/src/models/__tests__/veritableCloudagent.test.ts index 5db33a55..18b755ce 100644 --- a/src/models/__tests__/veritableCloudagent.test.ts +++ b/src/models/__tests__/veritableCloudagent.test.ts @@ -1,7 +1,12 @@ import { describe, it } from 'mocha' import { Env } from '../../env.js' -import { invalidResponse, successResponseTransformed } from './fixtures/cloudagentFixtures.js' +import { + createInviteSuccessResponse, + createInviteSuccessResponseTransformed, + invalidResponse, + receiveInviteSuccessResponse, +} from './fixtures/cloudagentFixtures.js' import { withCloudagentMock } from './helpers/mockCloudagent.js' import { InternalError } from '../../errors.js' @@ -15,18 +20,18 @@ describe('veritableCloudagent', () => { describe('createOutOfBandInvite', () => { describe('success', function () { - withCloudagentMock() + withCloudagentMock(`/v1/oob/create-invitation`, 200, createInviteSuccessResponse) it('should give back out-of-band invite', async () => { const environment = new Env() const cloudagent = new VeritableCloudagent(environment) const response = await cloudagent.createOutOfBandInvite({ companyName: 'Digital Catapult' }) - expect(response).deep.equal(successResponseTransformed) + expect(response).deep.equal(createInviteSuccessResponseTransformed) }) }) describe('error (response code)', function () { - withCloudagentMock(400, {}) + withCloudagentMock(`/v1/oob/create-invitation`, 400, {}) it('should throw internal error', async () => { const environment = new Env() @@ -43,7 +48,7 @@ describe('veritableCloudagent', () => { }) describe('error (response invalid)', function () { - withCloudagentMock(200, invalidResponse) + withCloudagentMock(`/v1/oob/create-invitation`, 200, invalidResponse) it('should throw internal error', async () => { const environment = new Env() @@ -59,4 +64,60 @@ describe('veritableCloudagent', () => { }) }) }) + + describe('receiveOutOfBandInvite', () => { + describe('success', function () { + withCloudagentMock('/v1/oob/receive-invitation-url', 200, receiveInviteSuccessResponse) + + it('should give back out-of-band invite', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment) + const response = await cloudagent.receiveOutOfBandInvite({ + companyName: 'Digital Catapult', + invitationUrl: 'http://example.com', + }) + expect(response).deep.equal(receiveInviteSuccessResponse) + }) + }) + + describe('error (response code)', function () { + withCloudagentMock('/v1/oob/receive-invitation-url', 400, {}) + + it('should throw internal error', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment) + + let error: unknown = null + try { + await cloudagent.receiveOutOfBandInvite({ + companyName: 'Digital Catapult', + invitationUrl: 'http://example.com', + }) + } catch (err) { + error = err + } + expect(error).instanceOf(InternalError) + }) + }) + + describe('error (response invalid)', function () { + withCloudagentMock('/v1/oob/receive-invitation-url', 200, invalidResponse) + + it('should throw internal error', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment) + + let error: unknown = null + try { + await cloudagent.receiveOutOfBandInvite({ + companyName: 'Digital Catapult', + invitationUrl: 'http://example.com', + }) + } catch (err) { + error = err + } + expect(error).instanceOf(InternalError) + }) + }) + }) }) diff --git a/src/models/db/migrations/20240607143859_add_connection_id.ts b/src/models/db/migrations/20240607143859_add_connection_id.ts new file mode 100644 index 00000000..07428a23 --- /dev/null +++ b/src/models/db/migrations/20240607143859_add_connection_id.ts @@ -0,0 +1,13 @@ +import type { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('connection', (def) => { + def.string('agent_connection_id').nullable().defaultTo(null) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('connection', (def) => { + def.dropColumn('agent_connection_id') + }) +} diff --git a/src/models/db/types.ts b/src/models/db/types.ts index 021bcb89..c15d900e 100644 --- a/src/models/db/types.ts +++ b/src/models/db/types.ts @@ -15,6 +15,7 @@ const insertConnection = z.object({ z.literal('verified_both'), z.literal('disconnected'), ]), + agent_connection_id: z.union([z.string(), z.null()]), }) const insertConnectionInvite = z.object({ diff --git a/src/models/strings.ts b/src/models/strings.ts index e126e3e3..c67889c5 100644 --- a/src/models/strings.ts +++ b/src/models/strings.ts @@ -38,3 +38,11 @@ export type EMAIL = string export type COMPANY_NUMBER = string export const companyNumberRegex = /^(((AC|CE|CS|FC|FE|GE|GS|IC|LP|NC|NF|NI|NL|NO|NP|OC|OE|PC|R0|RC|SA|SC|SE|SF|SG|SI|SL|SO|SR|SZ|ZC|\d{2})\d{6})|((IP|SP|RS)[A-Z\d]{6})|(SL\d{5}[\dA]))$/ + +/** + * Base64 url compatible string (see rfc4648 section-5) + * @pattern ^[a-zA-Z0-9_\-]+$ + * @example VGhpcyBpcyBzb21lIGV4YW1wbGUgdGV4dA + */ +export type BASE_64_URL = string +export const base64UrlRegex = /^[a-zA-Z0-9_\-]+$/ diff --git a/src/models/veritableCloudagent.ts b/src/models/veritableCloudagent.ts index 156a50c5..0458203f 100644 --- a/src/models/veritableCloudagent.ts +++ b/src/models/veritableCloudagent.ts @@ -14,41 +14,77 @@ const oobParser = z.object({ }) type OutOfBandInvite = z.infer +const receiveUrlParser = z.object({ + outOfBandRecord: z.object({ + id: z.string(), + }), + connectionRecord: z.object({ + id: z.string(), + }), +}) +type ReceiveUrlResponse = z.infer + @singleton() @injectable() export default class VeritableCloudagent { constructor(private env: Env) {} public async createOutOfBandInvite(params: { companyName: string }): Promise { - const url = `${this.env.get('CLOUDAGENT_ADMIN_ORIGIN')}/oob/create-invitation` - const request = { - alias: params.companyName, - handshake: true, - multiUseInvitation: false, - autoAcceptConnection: true, - } + return this.postRequest( + '/v1/oob/create-invitation', + { + alias: params.companyName, + handshake: true, + multiUseInvitation: false, + autoAcceptConnection: true, + }, + oobParser + ) + } + + public async receiveOutOfBandInvite(params: { + companyName: string + invitationUrl: string + }): Promise { + return this.postRequest( + '/v1/oob/receive-invitation-url', + { + alias: params.companyName, + autoAcceptConnection: true, + autoAcceptInvitation: true, + reuseConnection: true, + invitationUrl: params.invitationUrl, + }, + receiveUrlParser + ) + } + + private async postRequest( + path: string, + body: Record, + parser: z.ZodType + ): Promise { + const url = `${this.env.get('CLOUDAGENT_ADMIN_ORIGIN')}${path}` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(request), + body: JSON.stringify(body), }) if (!response.ok) { - throw new InternalError(`Unexpected error creating out-of-band-invite: ${response.statusText}`) + throw new InternalError(`Unexpected error calling POST ${path}: ${response.statusText}`) } try { - return oobParser.parse(await response.json()) + return parser.parse(await response.json()) } catch (err) { if (err instanceof Error) { - throw new InternalError( - `Error parsing out-of-band invite creation request response: ${err.name} - ${err.message}` - ) + throw new InternalError(`Error parsing response from calling POST ${path}: ${err.name} - ${err.message}`) } - throw new InternalError('Unknown error parsing oob invite create') + throw new InternalError(`Unknown error parsing response to calling POST ${path}`) } } } diff --git a/src/views/__tests__/connection.test.ts.snap b/src/views/__tests__/connection.test.ts.snap index 6aebedd5..d5834ccc 100644 --- a/src/views/__tests__/connection.test.ts.snap +++ b/src/views/__tests__/connection.test.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
<div>I own you</div>
Verified - Established Connection
some action
"`; +exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
<div>I own you</div>
Verified - Established Connection
some action
"`; -exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
Company B
'Pending Your Verification'
some action
Company C
Unverified
some action
Company D
Verified - Established Connection
some action
Company E
Pending Your Verification
some action
Company F
Pending Their Verification
some action
"`; +exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
Company B
'Pending Your Verification'
some action
Company C
Unverified
some action
Company D
Verified - Established Connection
some action
Company E
Pending Your Verification
some action
Company F
Pending Their Verification
some action
"`; -exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
No Connections for that search query. Try again or add a new connection
"`; +exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
No Connections for that search query. Try again or add a new connection
"`; -exports[`ConnectionTemplates listPage should render with single connection 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
"`; +exports[`ConnectionTemplates listPage should render with single connection 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
"`; diff --git a/src/views/__tests__/newConnection.test.ts b/src/views/__tests__/newConnection.test.ts deleted file mode 100644 index 43f9555f..00000000 --- a/src/views/__tests__/newConnection.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { expect } from 'chai' -import { describe, it } from 'mocha' - -import { - successResponse, - testErrorTargetBox, - testSuccessTargetBox, -} from '../../models/__tests__/fixtures/companyHouseFixtures.js' -import NewConnectionTemplates from '../newConnection.js' - -describe('NewConnectionTemplates', () => { - describe('show form', () => { - it('should render Error message with text error test', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.companyEmptyTextBox({ errorMessage: 'error test' }) - expect(rendered).to.matchSnapshot() - }) - - it('should render a valid company in filled company test box', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.companyFilledTextBox({ company: successResponse }) - expect(rendered).to.matchSnapshot() - }) - - it('should render form with a errormessage and invlaid response', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.companyFormInput({ targetBox: testErrorTargetBox, formStage: 'form' }) - expect(rendered).to.matchSnapshot() - }) - - it('should render form with a valid repsponse', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.companyFormInput({ targetBox: testSuccessTargetBox, formStage: 'form' }) - expect(rendered).to.matchSnapshot() - }) - - it('should render a confirmation page with given email and company number', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.companyFormInput({ - targetBox: testSuccessTargetBox, - formStage: 'confirmation', - email: '123@123.com', - companyNumber: successResponse.company_number, - }) - expect(rendered).to.matchSnapshot() - }) - - it('should render a success response page with a single button to return to home', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.companyFormInput({ targetBox: testSuccessTargetBox, formStage: 'success' }) - expect(rendered).to.matchSnapshot() - }) - - it('should a web page with the a form in an empty state', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.formPage(testErrorTargetBox, 'form') - expect(rendered).to.matchSnapshot() - }) - - it('should render a stepper html at stage 1', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.stepper({ formStage: 'form' }) - expect(rendered).to.matchSnapshot() - }) - - it('should render a stepper html at stage 2', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.stepper({ formStage: 'confirmation' }) - expect(rendered).to.matchSnapshot() - }) - - it('should render a stepper html at stage 3', async () => { - const templates = new NewConnectionTemplates() - const rendered = await templates.stepper({ formStage: 'success' }) - expect(rendered).to.matchSnapshot() - }) - }) -}) diff --git a/src/views/__tests__/newConnection.test.ts.snap b/src/views/__tests__/newConnection.test.ts.snap deleted file mode 100644 index b54daa29..00000000 --- a/src/views/__tests__/newConnection.test.ts.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NewConnectionTemplates show form should a web page with the a form in an empty state 1`] = `"Veritable - New Connection

New Connection

Invite New Connection
Step 1 of 3

Please confirm the details of the connection before sending

Company House Number: false

Email Address: false

After clicking submit, a connection invitation will be sent to their email and postal address.

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

This is a test error message

"`; - -exports[`NewConnectionTemplates show form should render Error message with text error test 1`] = `"

error test

"`; - -exports[`NewConnectionTemplates show form should render a confirmation page with given email and company number 1`] = `"
Step 2 of 3

Please confirm the details of the connection before sending

Company House Number: 07964699

Email Address: 123@123.com

After clicking submit, a connection invitation will be sent to their email and postal address.

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

Registered Office Address

DIGITAL CATAPULT

Level 9, 101 Euston Road

London

NW1 2RA

Registered Office Address

active

\\"Description
"`; - -exports[`NewConnectionTemplates show form should render a stepper html at stage 1 1`] = `"
Step 1 of 3
"`; - -exports[`NewConnectionTemplates show form should render a stepper html at stage 2 1`] = `"
Step 2 of 3
"`; - -exports[`NewConnectionTemplates show form should render a stepper html at stage 3 1`] = `"
Step 3 of 3
"`; - -exports[`NewConnectionTemplates show form should render a success response page with a single button to return to home 1`] = `"
Step 3 of 3

Please confirm the details of the connection before sending

Company House Number: 07964699

Email Address: undefined

After clicking submit, a connection invitation will be sent to their email and postal address.

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

Registered Office Address

DIGITAL CATAPULT

Level 9, 101 Euston Road

London

NW1 2RA

Registered Office Address

active

\\"Description
"`; - -exports[`NewConnectionTemplates show form should render a valid company in filled company test box 1`] = `"Registered Office Address

DIGITAL CATAPULT

Level 9, 101 Euston Road

London

NW1 2RA

Registered Office Address

active

"`; - -exports[`NewConnectionTemplates show form should render form with a errormessage and invlaid response 1`] = `"
Step 1 of 3

Please confirm the details of the connection before sending

Company House Number: false

Email Address: false

After clicking submit, a connection invitation will be sent to their email and postal address.

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

This is a test error message

"`; - -exports[`NewConnectionTemplates show form should render form with a valid repsponse 1`] = `"
Step 1 of 3

Please confirm the details of the connection before sending

Company House Number: 07964699

Email Address: undefined

After clicking submit, a connection invitation will be sent to their email and postal address.

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

Registered Office Address

DIGITAL CATAPULT

Level 9, 101 Euston Road

London

NW1 2RA

Registered Office Address

active

\\"Description
"`; diff --git a/src/views/common.tsx b/src/views/common.tsx index 09641240..37fc6b12 100644 --- a/src/views/common.tsx +++ b/src/views/common.tsx @@ -4,6 +4,7 @@ type PageProps = { title: string heading: string url: string + stylesheets?: string[] } type ButtonProps = { @@ -14,37 +15,39 @@ type ButtonProps = { disabled?: boolean outline?: boolean href?: string + fillButton?: boolean } type FormButtonProps = { name: string - display: string disabled?: boolean outline?: boolean value?: string + text?: string type?: string + fillButton?: boolean } export const ButtonIcon = (props: ButtonProps): JSX.Element => ( - -
- {props.showIcon && ( -
- )} - {props.name || 'unknown'} -
+
+ {props.showIcon && ( +
+ )} + {props.name || 'unknown'} ) export const FormButton = (props: FormButtonProps): JSX.Element => ( ) @@ -121,6 +124,9 @@ export const Page = (props: Html.PropsWithChildren): JSX.Element => ( + {(props.stylesheets || []).map((sheetName) => ( + + ))} {Html.escapeHtml(props.title)} diff --git a/src/views/connection.tsx b/src/views/connection.tsx index eaaffa79..31dfaf98 100644 --- a/src/views/connection.tsx +++ b/src/views/connection.tsx @@ -44,7 +44,20 @@ export default class ConnectionTemplates {
Connections Summary - + +
@@ -88,6 +101,7 @@ export default class ConnectionTemplates { disabled={true} name="some action" showIcon={true} + fillButton={true} /> diff --git a/src/views/newConnection.tsx b/src/views/newConnection.tsx deleted file mode 100644 index 736819c8..00000000 --- a/src/views/newConnection.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import Html from '@kitajs/html' -import { singleton } from 'tsyringe' -import { CompanyProfile } from '../models/companyHouseEntity.js' -import { COMPANY_NUMBER, EMAIL, companyNumberRegex } from '../models/strings.js' -import { ButtonIcon, FormButton, Page } from './common.js' - -export type CompanyProfileText = - | { - status: 'success' - company: CompanyProfile - } - | { - status: 'error' - errorMessage: string - } - -export type FormStage = 'form' | 'confirmation' | 'success' - -@singleton() -export default class newConnectionTemplates { - constructor() {} - - public formPage = (targetBox: CompanyProfileText, formStage: FormStage) => { - return ( - -
- Invite New Connection -
-
- -
-
- ) - } - - public companyFormInput = (params: { - targetBox: CompanyProfileText - formStage: FormStage - email?: EMAIL - companyNumber?: COMPANY_NUMBER - }): JSX.Element => { - const showForm = params.formStage === 'form' ? true : false - const showConfirmation = params.formStage === 'confirmation' ? true : false - const showSuccess = params.formStage === 'success' ? true : false - return ( - <> -
-
-
- - - - -
-

Please confirm the details of the connection before sending

-

- {Html.escapeHtml( - `Company House Number: ${params.targetBox.status === 'success' && params.targetBox.company.company_number}` - )} -

-

{Html.escapeHtml(`Email Address: ${params.targetBox.status === 'success' && params.email}`)}

-

After clicking submit, a connection invitation will be sent to their email and postal address.

-
-
-

- Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 - days to arrive, please wait for their verification and keep updated by viewing the verification - status. -

-
-
-
- {params.targetBox.status === 'error' ? ( - - ) : ( -
-
- -
-
- Description of the image -
-
- )} -
-
-
-
- -
- - - -
-
- - ) - } - - public companyFilledTextBox = ({ company }: { company: CompanyProfile }): JSX.Element => { - return ( - <> - Registered Office Address - -

{Html.escapeHtml(company.company_name)}

-

{Html.escapeHtml(company.registered_office_address.address_line_1)}

- {company?.registered_office_address?.address_line_2 && ( -

{Html.escapeHtml(company.registered_office_address.address_line_2)}

- )} - {company?.registered_office_address?.address_line_2 && ( -

{Html.escapeHtml(company.registered_office_address.address_line_2)}

- )} - {company?.registered_office_address?.care_of && ( -

{Html.escapeHtml(company.registered_office_address.care_of)}

- )} - {company?.registered_office_address?.locality && ( -

{Html.escapeHtml(company.registered_office_address.locality)}

- )} - {company?.registered_office_address?.po_box && ( -

{Html.escapeHtml(company.registered_office_address.po_box)}

- )} - {company?.registered_office_address?.postal_code && ( -

{Html.escapeHtml(company.registered_office_address.postal_code)}

- )} - {company?.registered_office_address?.country && ( -

{Html.escapeHtml(company.registered_office_address.country)}

- )} - {company?.registered_office_address?.premises && ( -

{Html.escapeHtml(company.registered_office_address.premises)}

- )} - {company?.registered_office_address?.region && ( -

{Html.escapeHtml(company.registered_office_address.region)}

- )} - - Registered Office Address - -

{Html.escapeHtml(company.company_status)}

- - ) - } - - public companyEmptyTextBox = ({ errorMessage }: { errorMessage: string }): JSX.Element => { - return

{Html.escapeHtml(errorMessage)}

- } - - public stepper = (params: { formStage: FormStage }): JSX.Element => { - if (params.formStage === 'form') { - return ( -
-
-
Step 1 of 3
-
- ) - } else if (params.formStage === 'confirmation') { - return ( -
-
-
Step 2 of 3
-
- ) - } else if (params.formStage === 'success') { - return ( -
-
-
Step 3 of 3
-
- ) - } else { - return
Stage Undefined
- } - } -} diff --git a/src/views/newConnection/__tests__/fixtures.ts b/src/views/newConnection/__tests__/fixtures.ts new file mode 100644 index 00000000..5ee4a798 --- /dev/null +++ b/src/views/newConnection/__tests__/fixtures.ts @@ -0,0 +1,29 @@ +import { CompanyProfile } from '../../../models/companyHouseEntity.js' +import { FormFeedback } from '../base.js' + +export const successResponse: CompanyProfile = { + registered_office_address: { + address_line_1: 'Level 9, 101 Euston Road', + postal_code: 'NW1 2RA', + locality: 'London', + }, + company_status: 'active', + registered_office_is_in_dispute: false, + company_name: 'DIGITAL CATAPULT', + company_number: '07964699', +} + +export const testErrorTargetBox: FormFeedback = { + type: 'error', + error: 'This is a test error message', +} + +export const testMessageTargetBox: FormFeedback = { + type: 'message', + message: 'This is a message', +} + +export const testSuccessTargetBox: FormFeedback = { + type: 'companyFound', + company: successResponse, +} diff --git a/src/views/newConnection/__tests__/fromInvite.test.ts b/src/views/newConnection/__tests__/fromInvite.test.ts new file mode 100644 index 00000000..05254815 --- /dev/null +++ b/src/views/newConnection/__tests__/fromInvite.test.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { FromInviteTemplates } from '../fromInvite.js' +import { testErrorTargetBox, testMessageTargetBox, testSuccessTargetBox } from './fixtures.js' + +describe('NewInviteTemplates', () => { + describe('show form', () => { + it('should render form with a error message and invalid response', async () => { + const templates = new FromInviteTemplates() + const rendered = await templates.fromInviteForm({ feedback: testMessageTargetBox, formStage: 'invite' }) + expect(rendered).to.matchSnapshot() + }) + + it('should render form with a valid response', async () => { + const templates = new FromInviteTemplates() + const rendered = await templates.fromInviteForm({ feedback: testSuccessTargetBox, formStage: 'success' }) + expect(rendered).to.matchSnapshot() + }) + + it('should render a web page with the a form in an empty state', async () => { + const templates = new FromInviteTemplates() + const rendered = await templates.fromInviteFormPage(testSuccessTargetBox) + expect(rendered).to.matchSnapshot() + }) + + it('should render a web page with the a form in an empty state', async () => { + const templates = new FromInviteTemplates() + const rendered = await templates.fromInviteFormPage(testMessageTargetBox) + expect(rendered).to.matchSnapshot() + }) + + it('should render a web page with the a form in an error state', async () => { + const templates = new FromInviteTemplates() + const rendered = await templates.fromInviteFormPage(testErrorTargetBox) + expect(rendered).to.matchSnapshot() + }) + }) +}) diff --git a/src/views/newConnection/__tests__/fromInvite.test.ts.snap b/src/views/newConnection/__tests__/fromInvite.test.ts.snap new file mode 100644 index 00000000..743632bb --- /dev/null +++ b/src/views/newConnection/__tests__/fromInvite.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewInviteTemplates show form should render a web page with the a form in an empty state 1`] = `"Veritable - New Connection

New Connection

Invite New Connection
*\\">
Step 1 of 2

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
Cancel
"`; + +exports[`NewInviteTemplates show form should render a web page with the a form in an empty state 2`] = `"Veritable - New Connection

New Connection

Invite New Connection
*\\">
Step 1 of 2
This is a message
Cancel
"`; + +exports[`NewInviteTemplates show form should render a web page with the a form in an error state 1`] = `"Veritable - New Connection

New Connection

Invite New Connection
*\\">
Step 1 of 2
This is a test error message
Cancel
"`; + +exports[`NewInviteTemplates show form should render form with a error message and invalid response 1`] = `"
*\\">
Step 1 of 2
This is a message
Cancel
"`; + +exports[`NewInviteTemplates show form should render form with a valid response 1`] = `"
*\\">
Step 2 of 2

Your connection has been established, but still needs to be verified. You should receive a verification letter at your registered business with instructions on how to do this. A reciprocal verification request has been sent in the post on your behalf to the address on the right to verify their identity

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; diff --git a/src/views/newConnection/__tests__/newInvite.test.ts b/src/views/newConnection/__tests__/newInvite.test.ts new file mode 100644 index 00000000..a36788d8 --- /dev/null +++ b/src/views/newConnection/__tests__/newInvite.test.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { NewInviteTemplates } from '../newInvite.js' +import { successResponse, testErrorTargetBox, testMessageTargetBox, testSuccessTargetBox } from './fixtures.js' + +describe('NewInviteTemplates', () => { + describe('show form', () => { + it('should render form with a error message and invalid response', async () => { + const templates = new NewInviteTemplates() + const rendered = await templates.newInviteForm({ feedback: testMessageTargetBox, formStage: 'form' }) + expect(rendered).to.matchSnapshot() + }) + + it('should render form with a valid response', async () => { + const templates = new NewInviteTemplates() + const rendered = await templates.newInviteForm({ feedback: testSuccessTargetBox, formStage: 'form' }) + expect(rendered).to.matchSnapshot() + }) + + it('should render a confirmation page with given email and company number', async () => { + const templates = new NewInviteTemplates() + const rendered = await templates.newInviteForm({ + feedback: testSuccessTargetBox, + formStage: 'confirmation', + email: '123@123.com', + companyNumber: successResponse.company_number, + }) + expect(rendered).to.matchSnapshot() + }) + + it('should render a success response page with a single button to return to home', async () => { + const templates = new NewInviteTemplates() + const rendered = await templates.newInviteForm({ feedback: testSuccessTargetBox, formStage: 'success' }) + expect(rendered).to.matchSnapshot() + }) + + it('should a web page with the a form in an empty state', async () => { + const templates = new NewInviteTemplates() + const rendered = await templates.newInviteFormPage(testMessageTargetBox) + expect(rendered).to.matchSnapshot() + }) + + it('should a web page with the a form in an error state', async () => { + const templates = new NewInviteTemplates() + const rendered = await templates.newInviteFormPage(testErrorTargetBox) + expect(rendered).to.matchSnapshot() + }) + }) +}) diff --git a/src/views/newConnection/__tests__/newInvite.test.ts.snap b/src/views/newConnection/__tests__/newInvite.test.ts.snap new file mode 100644 index 00000000..f5ba40cb --- /dev/null +++ b/src/views/newConnection/__tests__/newInvite.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewInviteTemplates show form should a web page with the a form in an empty state 1`] = `"Veritable - New Connection

New Connection

Invite New Connection
*\\">
Step 1 of 3
This is a message
Cancel
"`; + +exports[`NewInviteTemplates show form should a web page with the a form in an error state 1`] = `"Veritable - New Connection

New Connection

Invite New Connection
*\\">
Step 1 of 3
This is a test error message
Cancel
"`; + +exports[`NewInviteTemplates show form should render Error message with text error test 1`] = `"

error test

"`; + +exports[`NewInviteTemplates show form should render a confirmation page with given email and company number 1`] = `"
*\\">
Step 2 of 3

Please confirm the details of the connection before sending

Company House Number: 07964699

Email Address: 123@123.com

After clicking submit, a connection invitation will be sent to their email and postal address.

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; + +exports[`NewInviteTemplates show form should render a stepper html at stage 1 1`] = `"
Step 1 of 3
"`; + +exports[`NewInviteTemplates show form should render a stepper html at stage 2 1`] = `"
Step 2 of 3
"`; + +exports[`NewInviteTemplates show form should render a stepper html at stage 3 1`] = `"
Step 3 of 3
"`; + +exports[`NewInviteTemplates show form should render a success response page with a single button to return to home 1`] = `"
*\\">
Step 3 of 3

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; + +exports[`NewInviteTemplates show form should render a valid company in filled company test box 1`] = `"

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; + +exports[`NewInviteTemplates show form should render form with a error message and invalid response 1`] = `"
*\\">
Step 1 of 3
This is a message
Cancel
"`; + +exports[`NewInviteTemplates show form should render form with a valid response 1`] = `"
*\\">
Step 1 of 3

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
Cancel
"`; diff --git a/src/views/newConnection/base.tsx b/src/views/newConnection/base.tsx new file mode 100644 index 00000000..a5f8ea19 --- /dev/null +++ b/src/views/newConnection/base.tsx @@ -0,0 +1,139 @@ +import Html from '@kitajs/html' +import { CompanyProfile } from '../../models/companyHouseEntity.js' +import { ButtonIcon, FormButton } from '../common.js' + +export type FormFeedback = + | { + type: 'companyFound' + company: CompanyProfile + } + | { + type: 'message' + message: string + } + | { + type: 'error' + error: string + } + +export type FormAction = + | { + type: 'submit' + value: string + text: string + } + | { + type: 'link' + text: string + href: string + } + +export abstract class NewConnectionTemplates { + protected newConnectionForm = ( + props: Html.PropsWithChildren<{ + submitRoute: 'create-invitation' | 'receive-invitation' + feedback: FormFeedback + progressStep: number + progressStepCount: number + actions: FormAction[] + }> + ): JSX.Element => { + return ( +
+ + {props.children} + +
+ {props.actions.map((action, i) => { + const lastIndex = props.actions.length - 1 + switch (action.type) { + case 'link': + return + case 'submit': + return ( + + ) + } + })} +
+ + ) + } + + protected feedback = (props: { feedback: FormFeedback }): JSX.Element => { + switch (props.feedback.type) { + case 'message': + return + case 'companyFound': + return + case 'error': + return + } + } + + protected feedbackCompanyInfo = ({ company }: { company: CompanyProfile }): JSX.Element => { + const addressLines = [ + company.company_name, + company.registered_office_address.address_line_1, + company.registered_office_address.address_line_2, + company.registered_office_address.address_line_2, + company.registered_office_address.care_of, + company.registered_office_address.locality, + company.registered_office_address.po_box, + company.registered_office_address.postal_code, + company.registered_office_address.country, + company.registered_office_address.premises, + company.registered_office_address.region, + ].filter((x) => !!x) + + return ( + + ) + } + + protected feedbackMessage = ({ message, isError }: { message: string; isError: boolean }): JSX.Element => { + const messageClass = isError ? 'feedback-negative' : 'feedback-neutral' + return ( +
+ {Html.escapeHtml(message)} +
+ ) + } + + protected stepper = (params: { stage: number; total: number }): JSX.Element => { + return ( +
+
+ Step {params.stage} + of {params.total} +
+
+
+ ) + } +} diff --git a/src/views/newConnection/fromInvite.tsx b/src/views/newConnection/fromInvite.tsx new file mode 100644 index 00000000..2d2232ae --- /dev/null +++ b/src/views/newConnection/fromInvite.tsx @@ -0,0 +1,90 @@ +import Html from '@kitajs/html' +import { singleton } from 'tsyringe' +import { Page } from '../common.js' +import { FormFeedback, NewConnectionTemplates } from './base.js' + +export type FromInviteFormStage = 'invite' | 'success' + +@singleton() +export class FromInviteTemplates extends NewConnectionTemplates { + constructor() { + super() + } + + public fromInviteFormPage = (feedback: FormFeedback) => { + return ( + +
+ Invite New Connection +
+
+ +
+
+ ) + } + + public fromInviteForm = (props: { formStage: FromInviteFormStage; feedback: FormFeedback }): JSX.Element => { + switch (props.formStage) { + case 'invite': + return + case 'success': + return + } + } + + private fromInviteInvite = (props: { invite?: string; feedback: FormFeedback }): JSX.Element => { + return ( + +
+ +
+
+ ) + } + + private fromInviteSuccess = (props: { feedback: FormFeedback }): JSX.Element => { + return ( + +
+

+ Your connection has been established, but still needs to be verified. You should receive a verification + letter at your registered business with instructions on how to do this. A reciprocal verification request + has been sent in the post on your behalf to the address on the right to verify their identity +

+
+
+ ) + } +} diff --git a/src/views/newConnection/newInvite.tsx b/src/views/newConnection/newInvite.tsx new file mode 100644 index 00000000..43138323 --- /dev/null +++ b/src/views/newConnection/newInvite.tsx @@ -0,0 +1,158 @@ +import Html from '@kitajs/html' +import { singleton } from 'tsyringe' +import { COMPANY_NUMBER, EMAIL, companyNumberRegex } from '../../models/strings.js' +import { Page } from '../common.js' +import { FormFeedback, NewConnectionTemplates } from './base.js' + +export type NewInviteFormStage = 'form' | 'confirmation' | 'success' + +@singleton() +export class NewInviteTemplates extends NewConnectionTemplates { + constructor() { + super() + } + + public newInviteFormPage = (feedback: FormFeedback) => { + return ( + +
+ Invite New Connection +
+
+ +
+
+ ) + } + + public newInviteForm = (props: { + formStage: NewInviteFormStage + companyNumber?: COMPANY_NUMBER + email?: EMAIL + feedback: FormFeedback + }): JSX.Element => { + switch (props.formStage) { + case 'form': + return + case 'confirmation': + return + case 'success': + return + } + } + + private newInviteInput = (props: { + companyNumber?: COMPANY_NUMBER + email?: EMAIL + feedback: FormFeedback + }): JSX.Element => { + return ( + + + + + ) + } + + private newInviteConfirmation = (props: { + companyNumber?: COMPANY_NUMBER + email?: EMAIL + feedback: FormFeedback + }): JSX.Element => { + return ( + + + +
+

Please confirm the details of the connection before sending

+

+ {Html.escapeHtml( + `Company House Number: ${props.feedback.type === 'companyFound' && props.feedback.company.company_number}` + )} +

+

{Html.escapeHtml(`Email Address: ${props.feedback.type === 'companyFound' && props.email}`)}

+

After clicking submit, a connection invitation will be sent to their email and postal address.

+
+
+ ) + } + + private newInviteSuccess = (props: { feedback: FormFeedback }): JSX.Element => { + return ( + +
+

+ Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days + to arrive, please wait for their verification and keep updated by viewing the verification status. +

+
+
+ ) + } +} diff --git a/test/helpers/cloudagent.ts b/test/helpers/cloudagent.ts new file mode 100644 index 00000000..9d2d0753 --- /dev/null +++ b/test/helpers/cloudagent.ts @@ -0,0 +1,26 @@ +import { Env } from '../../src/env.js' +import VeritableCloudagent from '../../src/models/veritableCloudagent.js' + +import { validCompanyName, validCompanyNumber } from './fixtures.js' + +export function withBobCloudAgentInvite(context: { invite: string }) { + let agent = new VeritableCloudagent({ + get(name) { + if (name === 'CLOUDAGENT_ADMIN_ORIGIN') { + return 'http://localhost:3101' + } + throw new Error('Unexpected env variable request') + }, + } as Env) + + beforeEach(async function () { + const invite = await agent.createOutOfBandInvite({ companyName: validCompanyName }) + context.invite = Buffer.from( + JSON.stringify({ + companyNumber: validCompanyNumber, + inviteUrl: invite.invitationUrl, + }), + 'utf8' + ).toString('base64url') + }) +} diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts index 5a445dca..9bd466dc 100644 --- a/test/helpers/fixtures.ts +++ b/test/helpers/fixtures.ts @@ -1,9 +1,7 @@ import { CompanyProfile } from '../../src/models/companyHouseEntity.js' -import { CompanyProfileText } from '../../src/views/newConnection.js' export const validCompanyNumber = '07964699' -export const invalidCompanyNumber = '079646992' -export const noCompanyNumber = '' +export const validCompanyName = 'DIGITAL CATAPULT' export const successResponse: CompanyProfile = { registered_office_address: { @@ -13,16 +11,6 @@ export const successResponse: CompanyProfile = { }, company_status: 'active', registered_office_is_in_dispute: false, - company_name: 'DIGITAL CATAPULT', - company_number: '07964699', -} - -export const testErrorTargetBox: CompanyProfileText = { - status: 'error', - errorMessage: 'This is a test error message', -} - -export const testSuccessTargetBox: CompanyProfileText = { - status: 'success', - company: successResponse, + company_name: validCompanyName, + company_number: validCompanyNumber, } diff --git a/test/integration/newConnection.test.ts b/test/integration/newConnection.test.ts index 56ef31fd..613eeff3 100644 --- a/test/integration/newConnection.test.ts +++ b/test/integration/newConnection.test.ts @@ -1,11 +1,11 @@ import { expect } from 'chai' import express from 'express' import { describe, it } from 'mocha' -import sinon from 'sinon' import { container } from 'tsyringe' import Database from '../../src/models/db/index.js' import createHttpServer from '../../src/server.js' +import { withBobCloudAgentInvite } from '../helpers/cloudagent.js' import { withCompanyHouseMock } from '../helpers/companyHouse.js' import { cleanup } from '../helpers/db.js' import { validCompanyNumber } from '../helpers/fixtures.js' @@ -18,20 +18,52 @@ describe('NewConnectionController', () => { afterEach(async () => { await cleanup() - sinon.restore() }) withCompanyHouseMock() - describe('happy path', function () { + describe('create invitation (happy path)', function () { let response: Awaited> beforeEach(async () => { await cleanup() app = await createHttpServer() - response = await post(app, '/connection/new/submit', { + response = await post(app, '/connection/new/create-invitation', { companyNumber: validCompanyNumber, email: 'alice@example.com', - submitButton: 'Submit', + action: 'submit', + }) + }) + + it('should return success', async () => { + expect(response.status).to.equal(200) + }) + + it('should insert new connection into db', async () => { + const connectionRows = await db.get('connection') + expect(connectionRows.length).to.equal(1) + expect(connectionRows[0]).to.deep.contain({ + company_name: 'DIGITAL CATAPULT', + company_number: '07964699', + status: 'pending', + }) + + const invites = await db.get('connection_invite', { connection_id: connectionRows[0].id }) + expect(invites.length).to.equal(1) + }) + }) + + describe('receive invitation (happy path)', function () { + let response: Awaited> + const context: { invite: string } = { invite: '' } + + withBobCloudAgentInvite(context) + + beforeEach(async () => { + await cleanup() + app = await createHttpServer() + response = await post(app, '/connection/new/receive-invitation', { + invite: context.invite, + action: 'createConnection', }) }) diff --git a/test/test.env b/test/test.env index fe39cb16..8aeca259 100644 --- a/test/test.env +++ b/test/test.env @@ -1 +1 @@ -COMPANY_PROFILE_API_KEY=API_KEY \ No newline at end of file +COMPANY_PROFILE_API_KEY=API_KEY