From c9b7810ceab63f21ac1c9be6e54147ea1d93b6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 8 Jan 2024 12:01:43 +0000 Subject: [PATCH 01/10] chore(wip): Update sync script for `team_themes` table (#2633) --- scripts/seed-database/container.sh | 2 +- scripts/seed-database/write/main.sql | 3 +- scripts/seed-database/write/team_themes.sql | 41 +++++++++++++++++++++ scripts/seed-database/write/teams.sql | 2 - 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 scripts/seed-database/write/team_themes.sql diff --git a/scripts/seed-database/container.sh b/scripts/seed-database/container.sh index 4789a3e33c..b7327d453e 100755 --- a/scripts/seed-database/container.sh +++ b/scripts/seed-database/container.sh @@ -18,7 +18,7 @@ mkdir -p /tmp # Create sync.sql file for all our comnands which will be executed in a single transaction touch '/tmp/sync.sql' -tables=(flows users teams flow_document_templates team_members) +tables=(flows users teams flow_document_templates team_members team_themes) # run copy commands on remote db for table in "${tables[@]}"; do diff --git a/scripts/seed-database/write/main.sql b/scripts/seed-database/write/main.sql index f39a94cb10..5ae353a990 100644 --- a/scripts/seed-database/write/main.sql +++ b/scripts/seed-database/write/main.sql @@ -4,4 +4,5 @@ \include write/flow_document_templates.sql \include write/published_flows.sql \include write/team_members.sql -\include write/team_integrations.sql \ No newline at end of file +\include write/team_integrations.sql +\include write/team_themes.sql \ No newline at end of file diff --git a/scripts/seed-database/write/team_themes.sql b/scripts/seed-database/write/team_themes.sql new file mode 100644 index 0000000000..68b42ebfe0 --- /dev/null +++ b/scripts/seed-database/write/team_themes.sql @@ -0,0 +1,41 @@ +-- insert teams_themes overwriting conflicts +CREATE TEMPORARY TABLE sync_team_themes ( + id integer, + team_id integer, + primary_colour text, + secondary_colour text, + logo text, + favicon text +); + +\copy sync_team_themes FROM '/tmp/team_themes.csv' WITH (FORMAT csv, DELIMITER ';'); + +INSERT INTO + team_themes ( + id, + team_id, + primary_colour, + secondary_colour, + logo, + favicon + ) +SELECT + id, + team_id, + primary_colour, + secondary_colour, + logo, + favicon +FROM + sync_team_themes ON CONFLICT (id) DO +UPDATE +SET + team_id = EXCLUDED.team_id, + primary_colour = EXCLUDED.primary_colour, + secondary_colour = EXCLUDED.secondary_colour, + logo = EXCLUDED.logo, + favicon = EXCLUDED.favicon; +SELECT + setval('team_themes_id_seq', max(id)) +FROM + team_themes; \ No newline at end of file diff --git a/scripts/seed-database/write/teams.sql b/scripts/seed-database/write/teams.sql index fcecd7cc85..7cc3c73dde 100644 --- a/scripts/seed-database/write/teams.sql +++ b/scripts/seed-database/write/teams.sql @@ -3,8 +3,6 @@ CREATE TEMPORARY TABLE sync_teams ( id integer, name text, slug text, - -- TODO: Drop this and fetch from team_themes - theme jsonb, created_at timestamptz, updated_at timestamptz, settings jsonb, From 3d6283d8f5c00cfff6767d01a67e080ccb032484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 8 Jan 2024 12:22:10 +0000 Subject: [PATCH 02/10] fix: Handle duplicate team_id on re-sync [skip pizza] (#2636) --- scripts/seed-database/write/team_themes.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/seed-database/write/team_themes.sql b/scripts/seed-database/write/team_themes.sql index 68b42ebfe0..3060ef58ff 100644 --- a/scripts/seed-database/write/team_themes.sql +++ b/scripts/seed-database/write/team_themes.sql @@ -30,7 +30,6 @@ FROM sync_team_themes ON CONFLICT (id) DO UPDATE SET - team_id = EXCLUDED.team_id, primary_colour = EXCLUDED.primary_colour, secondary_colour = EXCLUDED.secondary_colour, logo = EXCLUDED.logo, From 25840a282fc7423d686149526895cc08dde4e504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 8 Jan 2024 13:37:29 +0000 Subject: [PATCH 03/10] feat: Set `httpOnly` flag in cookie, use cookie for auth (#2634) * feat: Refactor cookie generation, set httpOnly flag * feat: Update frontend code to handle httpOnly cookie * test(e2e): Update getUser URL * feat: Handle origin CORS header * fix: Update tiptap/link to resolve HTMLAttributes issue * fix: Detect presence of httpOnly cookie * fix: Remove second cookie on logout also * fix: Add additional cookie to e2e tests * fix: Rename auth cookie, store jwt in user store * feat: Get JWT from store, not cookie * fix: Rever to cookie in `authHttpLink` to avoid race condition * fix(e2e): Use string value for browserContext cookie * fix(api): Resolve CORS issue by allowing auth header --- api.planx.uk/modules/auth/controller.ts | 110 +++++++++--------- api.planx.uk/modules/user/controller.ts | 9 +- api.planx.uk/modules/user/docs.yaml | 3 + api.planx.uk/server.ts | 17 ++- .../ui-driven/src/create-flow/helpers.ts | 7 +- e2e/tests/ui-driven/src/globalHelpers.ts | 6 + editor.planx.uk/package.json | 3 +- editor.planx.uk/pnpm-lock.yaml | 16 +-- .../FileUploadAndLabel/Editor.test.tsx | 1 + editor.planx.uk/src/api/upload.ts | 4 +- editor.planx.uk/src/client/index.ts | 5 +- editor.planx.uk/src/index.tsx | 39 +++---- .../src/pages/FlowEditor/lib/store/editor.ts | 11 +- .../src/pages/FlowEditor/lib/store/user.ts | 28 +++-- editor.planx.uk/src/routes/index.tsx | 2 +- .../src/routes/views/authenticated.tsx | 8 +- 16 files changed, 135 insertions(+), 134 deletions(-) diff --git a/api.planx.uk/modules/auth/controller.ts b/api.planx.uk/modules/auth/controller.ts index 69ac070316..dc84564b37 100644 --- a/api.planx.uk/modules/auth/controller.ts +++ b/api.planx.uk/modules/auth/controller.ts @@ -1,8 +1,6 @@ import { CookieOptions, RequestHandler, Response } from "express"; import { Request } from "express-jwt"; -import { isLiveEnv } from "../../helpers"; - export const failedLogin: RequestHandler = (_req, _res, next) => next({ status: 401, @@ -17,61 +15,63 @@ export const logout: RequestHandler = (req, res) => { }; export const handleSuccess = (req: Request, res: Response) => { - if (req.user) { - const { returnTo = process.env.EDITOR_URL_EXT } = req.session!; - - const domain = (() => { - if (isLiveEnv()) { - if (returnTo?.includes("editor.planx.")) { - // user is logging in to staging from editor.planx.dev - // or production from editor.planx.uk - return `.${new URL(returnTo).host}`; - } else { - // user is logging in from either a netlify preview build, - // or from localhost, to staging (or production... temporarily) - return undefined; - } - } else { - // user is logging in from localhost, to development - return "localhost"; - } - })(); - - if (domain) { - // As domain is set, we know that we're either redirecting back to - // editor.planx.dev/login, editor.planx.uk, or localhost:PORT - // (if this code is running in development). With the respective - // domain set in the cookie. - const cookie: CookieOptions = { - domain, - maxAge: new Date( - new Date().setFullYear(new Date().getFullYear() + 1), - ).getTime(), - httpOnly: false, - }; - - if (isLiveEnv()) { - cookie.secure = true; - cookie.sameSite = "none"; - } - - res.cookie("jwt", req.user.jwt, cookie); - - res.redirect(returnTo); - } else { - // Redirect back to localhost:PORT/login (if this API is in staging or - // production), or a netlify preview build url. As the login page is on a - // different domain to whatever this API is running on, we can't set a - // cookie. To solve this issue we inject the JWT into the return url as - // a parameter that can be extracted by the frontend code instead. - const url = new URL(returnTo); - url.searchParams.set("jwt", req.user.jwt); - res.redirect(url.href); - } - } else { - res.json({ + if (!req.user) { + return res.json({ message: "no user", success: true, }); } + + // Check referrer of original request + // This means requests from Pizzas to the staging API will not get flagged as `isStagingOrProd` + const { returnTo = process.env.EDITOR_URL_EXT } = req.session!; + if (!returnTo) throw Error("Can't generate returnTo URL from session"); + + const isStagingOrProd = returnTo.includes("editor.planx."); + + isStagingOrProd + ? setJWTCookie(returnTo, res, req) + : setJWTSearchParams(returnTo, res, req); }; + +/** + * Handle auth for staging and production + * + * Use a httpOnly cookie to pass the JWT securely back to the client. + * The client will then use the JWT to make authenticated requests to the API. + */ +function setJWTCookie(returnTo: string, res: Response, req: Request) { + const defaultCookieOptions: CookieOptions = { + domain: `.${new URL(returnTo).host}`, + maxAge: new Date( + new Date().setFullYear(new Date().getFullYear() + 1), + ).getTime(), + sameSite: "none", + }; + + const httpOnlyCookieOptions: CookieOptions = { + ...defaultCookieOptions, + httpOnly: true, + secure: true, + }; + + // Set secure, httpOnly cookie with JWT + res.cookie("jwt", req.user!.jwt, httpOnlyCookieOptions); + + // Set second cookie which can be read by browser to detect presence of the unreadable httpOnly cookie + res.cookie("auth", { loggedIn: true }, defaultCookieOptions); + + res.redirect(returnTo); +} + +/** + * Handle auth for local development and Pizzas + * + * We can't use cookies cross-domain. + * Inject the JWT into the return URL, which can then be set as a cookie by the frontend + */ +function setJWTSearchParams(returnTo: string, res: Response, req: Request) { + const url = new URL(returnTo); + url.searchParams.set("jwt", req.user!.jwt); + res.redirect(url.href); +} diff --git a/api.planx.uk/modules/user/controller.ts b/api.planx.uk/modules/user/controller.ts index b7a83cb7f4..11ea5cf0d1 100644 --- a/api.planx.uk/modules/user/controller.ts +++ b/api.planx.uk/modules/user/controller.ts @@ -69,7 +69,7 @@ export const deleteUser: DeleteUser = async (_req, res, next) => { export const getLoggedInUserDetails: RequestHandler< Record, - User + User & { jwt: string | undefined } > = async (_req, res, next) => { try { const $client = getClient(); @@ -88,7 +88,12 @@ export const getLoggedInUserDetails: RequestHandler< status: 400, }); - res.json(user); + const jwt = userContext.getStore()?.user.jwt; + + res.json({ + ...user, + jwt: jwt, + }); } catch (error) { next(error); } diff --git a/api.planx.uk/modules/user/docs.yaml b/api.planx.uk/modules/user/docs.yaml index c774fd5453..5e65093164 100644 --- a/api.planx.uk/modules/user/docs.yaml +++ b/api.planx.uk/modules/user/docs.yaml @@ -91,6 +91,9 @@ paths: isPlatformAdmin: type: boolean example: true + jwt: + type: string + example: xxxxx.yyyyy.zzzzz teams: type: array items: diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 5d0d2c18f7..0285331c74 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -38,19 +38,18 @@ useSwaggerDocs(app); app.set("trust proxy", 1); -app.use((req, res, next) => { - res.header("Access-Control-Allow-Origin", req.headers.origin); - res.header( - "Access-Control-Allow-Headers", - "Origin, X-Requested-With, Content-Type, Accept", - ); - next(); -}); - app.use( cors({ credentials: true, methods: "*", + origin: process.env.EDITOR_URL_EXT, + allowedHeaders: [ + "Accept", + "Authorization", + "Content-Type", + "Origin", + "X-Requested-With", + ], }), ); diff --git a/e2e/tests/ui-driven/src/create-flow/helpers.ts b/e2e/tests/ui-driven/src/create-flow/helpers.ts index 2bc95b7eaf..edaea7b683 100644 --- a/e2e/tests/ui-driven/src/create-flow/helpers.ts +++ b/e2e/tests/ui-driven/src/create-flow/helpers.ts @@ -1,11 +1,8 @@ import { Browser, Page, Request } from "@playwright/test"; import { createAuthenticatedSession } from "../globalHelpers"; -export const isGetUserRequest = (req: Request) => { - const isHasuraRequest = req.url().includes("/graphql"); - const isGetUserOperation = req.postData()?.toString().includes("GetUserById"); - return Boolean(isHasuraRequest && isGetUserOperation); -}; +export const isGetUserRequest = (req: Request) => + req.url().includes("/user/me"); export async function getAdminPage({ browser, diff --git a/e2e/tests/ui-driven/src/globalHelpers.ts b/e2e/tests/ui-driven/src/globalHelpers.ts index 9a99af6e11..a6f8552b90 100644 --- a/e2e/tests/ui-driven/src/globalHelpers.ts +++ b/e2e/tests/ui-driven/src/globalHelpers.ts @@ -62,6 +62,12 @@ export async function createAuthenticatedSession({ path: "/", value: token, }, + { + name: "auth", + domain: "localhost", + path: "/", + value: JSON.stringify({ loggedIn: true }), + }, ]); return page; } diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 9540df5fd0..f53be79f63 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -25,7 +25,7 @@ "@tiptap/extension-history": "^2.0.3", "@tiptap/extension-image": "^2.0.3", "@tiptap/extension-italic": "^2.0.3", - "@tiptap/extension-link": "^2.0.3", + "@tiptap/extension-link": "^2.1.13", "@tiptap/extension-list-item": "^2.0.3", "@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-ordered-list": "^2.1.13", @@ -52,7 +52,6 @@ "graphql-tag": "^2.12.6", "immer": "^9.0.21", "js-cookie": "^3.0.5", - "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "marked": "^4.3.0", "mathjs": "^11.8.2", diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index 907cbb4607..3e8430c0f5 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -78,8 +78,8 @@ dependencies: specifier: ^2.0.3 version: 2.0.3(@tiptap/core@2.0.3) '@tiptap/extension-link': - specifier: ^2.0.3 - version: 2.0.3(@tiptap/core@2.0.3)(@tiptap/pm@2.0.3) + specifier: ^2.1.13 + version: 2.1.13(@tiptap/core@2.0.3)(@tiptap/pm@2.0.3) '@tiptap/extension-list-item': specifier: ^2.0.3 version: 2.0.3(@tiptap/core@2.0.3) @@ -158,9 +158,6 @@ dependencies: js-cookie: specifier: ^3.0.5 version: 3.0.5 - jwt-decode: - specifier: ^4.0.0 - version: 4.0.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -7331,8 +7328,8 @@ packages: '@tiptap/core': 2.0.3(@tiptap/pm@2.0.3) dev: false - /@tiptap/extension-link@2.0.3(@tiptap/core@2.0.3)(@tiptap/pm@2.0.3): - resolution: {integrity: sha512-H72tXQ5rkVCkAhFaf08fbEU7EBUCK0uocsqOF+4th9sOlrhfgyJtc8Jv5EXPDpxNgG5jixSqWBo0zKXQm9s9eg==} + /@tiptap/extension-link@2.1.13(@tiptap/core@2.0.3)(@tiptap/pm@2.0.3): + resolution: {integrity: sha512-wuGMf3zRtMHhMrKm9l6Tft5M2N21Z0UP1dZ5t1IlOAvOeYV2QZ5UynwFryxGKLO0NslCBLF/4b/HAdNXbfXWUA==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 @@ -14632,11 +14629,6 @@ packages: setimmediate: 1.0.5 dev: false - /jwt-decode@4.0.0: - resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} - engines: {node: '>=18'} - dev: false - /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: diff --git a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.test.tsx b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.test.tsx index 4b8d71140a..1c3b97a4cd 100644 --- a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.test.tsx +++ b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.test.tsx @@ -19,6 +19,7 @@ describe("FileUploadAndLabel - Editor Modal", () => { isPlatformAdmin: true, email: "test@test.com", teams: [], + jwt: "x.y.z", }); }); diff --git a/editor.planx.uk/src/api/upload.ts b/editor.planx.uk/src/api/upload.ts index bc64324dd3..9806d3db72 100644 --- a/editor.planx.uk/src/api/upload.ts +++ b/editor.planx.uk/src/api/upload.ts @@ -1,5 +1,5 @@ import axios, { RawAxiosRequestHeaders } from "axios"; -import { getCookie } from "lib/cookie"; +import { useStore } from "pages/FlowEditor/lib/store"; export { uploadPrivateFile, uploadPublicFile }; @@ -9,7 +9,7 @@ async function uploadPublicFile( file: any, { onProgress }: { onProgress?: (p: any) => void } = {}, ) { - const token = getCookie("jwt"); + const token = useStore.getState().jwt; const authRequestHeader = { Authorization: `Bearer ${token}` }; const { data } = await handleUpload( file, diff --git a/editor.planx.uk/src/client/index.ts b/editor.planx.uk/src/client/index.ts index 9f40abd271..3922716ee4 100644 --- a/editor.planx.uk/src/client/index.ts +++ b/editor.planx.uk/src/client/index.ts @@ -1,5 +1,6 @@ import { CoreDomainClient } from "@opensystemslab/planx-core"; -import { getCookie } from "lib/cookie"; +import { useStore } from "pages/FlowEditor/lib/store"; + /** * core doesn't expose a graphql interface like the graphql/hasura clients do * instead, it encapsulates query and business logic to only expose declarative interfaces @@ -10,5 +11,5 @@ export const _public = new CoreDomainClient({ export const _client = new CoreDomainClient({ targetURL: process.env.REACT_APP_HASURA_URL!, - auth: { jwt: getCookie("jwt") || "" }, + auth: { jwt: useStore.getState().jwt || "" }, }); diff --git a/editor.planx.uk/src/index.tsx b/editor.planx.uk/src/index.tsx index 2f218a7b18..f4d0fa29b3 100644 --- a/editor.planx.uk/src/index.tsx +++ b/editor.planx.uk/src/index.tsx @@ -9,7 +9,6 @@ import { ApolloProvider } from "@apollo/client"; import CssBaseline from "@mui/material/CssBaseline"; import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; import { MyMap } from "@opensystemslab/map"; -import { jwtDecode } from "jwt-decode"; import { getCookie, setCookie } from "lib/cookie"; import ErrorPage from "pages/ErrorPage"; import { AnalyticsProvider } from "pages/FlowEditor/lib/analyticsProvider"; @@ -36,30 +35,20 @@ if (!window.customElements.get("my-map")) { } const hasJWT = (): boolean | void => { - let jwt = getCookie("jwt"); - if (jwt) { - try { - if ( - Number( - (jwtDecode(jwt) as any)["https://hasura.io/jwt/claims"][ - "x-hasura-user-id" - ], - ) > 0 - ) { - return true; - } - } catch (e) {} - window.location.href = "/logout"; - } else { - jwt = new URLSearchParams(window.location.search).get("jwt"); - if (jwt) { - setCookie("jwt", jwt); - // set the jwt, and remove it from the url, then re-run this function - window.location.href = "/"; - } else { - return false; - } - } + // This cookie indicates the presence of the secure httpOnly "jwt" cookie + const authCookie = getCookie("auth"); + if (authCookie) return true; + + // If JWT not set via cookie, check search params + const jwtSearchParams = new URLSearchParams(window.location.search).get( + "jwt", + ); + if (!jwtSearchParams) return false; + + // Remove JWT from URL, and re-run this function + setCookie("jwt", jwtSearchParams); + setCookie("auth", { loggedIn: true }); + window.location.href = "/"; }; const Layout: React.FC<{ diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts index b60b5f6784..5e866dea52 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -10,7 +10,6 @@ import { update, } from "@planx/graph"; import axios from "axios"; -import { getCookie } from "lib/cookie"; import { client } from "lib/graphql"; import debounce from "lodash/debounce"; import isEmpty from "lodash/isEmpty"; @@ -21,7 +20,7 @@ import type { StateCreator } from "zustand"; import { FlowLayout } from "../../components/Flow"; import { connectToDB, getConnection } from "./../sharedb"; -import type { Store } from "."; +import { type Store } from "."; import type { SharedStore } from "./shared"; import { UserStore } from "./user"; @@ -141,7 +140,7 @@ export const editorStore: StateCreator< }, copyFlow: async (flowId: string) => { - const token = getCookie("jwt"); + const token = get().jwt; // when copying a flow, we make nodeIds unique by replacing part of the original nodeId string. // the onboarding script will often provide a meaningful string reflecting the team name (eg "LAM"), @@ -237,7 +236,7 @@ export const editorStore: StateCreator< }, validateAndDiffFlow(flowId: string) { - const token = getCookie("jwt"); + const token = get().jwt; return axios.post( `${process.env.REACT_APP_API_URL}/flows/${flowId}/diff`, @@ -340,7 +339,7 @@ export const editorStore: StateCreator< return Promise.resolve(); } - const token = getCookie("jwt"); + const token = get().jwt; return axios .post( @@ -390,7 +389,7 @@ export const editorStore: StateCreator< }, publishFlow(flowId: string, summary?: string) { - const token = getCookie("jwt"); + const token = get().jwt; const urlWithParams = (url: string, params: any) => [url, new URLSearchParams(omitBy(params, isEmpty))] diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts index da46a652d8..dcb11681f2 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts @@ -1,23 +1,24 @@ import { User, UserTeams } from "@opensystemslab/planx-core/types"; +import axios from "axios"; import { _client } from "client"; -import { jwtDecode } from "jwt-decode"; import { Team } from "types"; import type { StateCreator } from "zustand"; export interface UserStore { user?: User; + jwt?: string; - setUser: (user: User) => void; + setUser: (user: User & { jwt: string }) => void; getUser: () => User | undefined; canUserEditTeam: (teamSlug: Team["slug"]) => boolean; - initUserStore: (jwt: string) => Promise; + initUserStore: () => Promise; } export const userStore: StateCreator = ( set, get, ) => ({ - setUser: (user: User) => set({ user }), + setUser: ({ jwt, ...user }) => set({ jwt, user }), getUser: () => get().user, @@ -31,15 +32,24 @@ export const userStore: StateCreator = ( return user.isPlatformAdmin || user.teams.some(hasTeamEditorRole); }, - async initUserStore(jwt: string) { + async initUserStore() { const { getUser, setUser } = get(); if (getUser()) return; - const id = (jwtDecode(jwt) as any)["sub"]; - const user = await _client.user.getById(id); - if (!user) throw new Error(`Failed to get user with ID ${id}`); - + const user = await getLoggedInUser(); setUser(user); }, }); + +const getLoggedInUser = async () => { + const url = `${process.env.REACT_APP_API_URL}/user/me`; + try { + const response = await axios.get(url, { + withCredentials: true, + }); + return response.data; + } catch (error) { + throw Error("Failed to fetch user matching JWT cookie"); + } +}; diff --git a/editor.planx.uk/src/routes/index.tsx b/editor.planx.uk/src/routes/index.tsx index 9acce1e60f..0540fafff2 100644 --- a/editor.planx.uk/src/routes/index.tsx +++ b/editor.planx.uk/src/routes/index.tsx @@ -35,7 +35,7 @@ const editorRoutes = mount({ } catch (err) { console.error(err); } finally { - const cookieString = `jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + const cookieString = `auth=; jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; // remove jwt cookie for non planx domains (netlify preview urls) document.cookie = cookieString; // remove jwt cookie for planx domains (staging and production) diff --git a/editor.planx.uk/src/routes/views/authenticated.tsx b/editor.planx.uk/src/routes/views/authenticated.tsx index e621d788aa..a17500fdb1 100644 --- a/editor.planx.uk/src/routes/views/authenticated.tsx +++ b/editor.planx.uk/src/routes/views/authenticated.tsx @@ -8,13 +8,13 @@ import AuthenticatedLayout from "../../pages/layout/AuthenticatedLayout"; /** * View wrapper for all authenticated routes - * Parses JWT and inits user store + * Initialises user store */ export const authenticatedView = async () => { - const jwt = getCookie("jwt"); - if (!jwt) return redirect("/login"); + const authCookie = getCookie("auth"); + if (!authCookie) return redirect("/login"); - await useStore.getState().initUserStore(jwt); + await useStore.getState().initUserStore(); useStore.getState().setPreviewEnvironment("editor"); From 150d5644bef5521c54640daedb4bbef8ff6a3639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 8 Jan 2024 14:24:04 +0000 Subject: [PATCH 04/10] fix: Add `secure` flag to auth cookie (#2637) --- api.planx.uk/modules/auth/controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.planx.uk/modules/auth/controller.ts b/api.planx.uk/modules/auth/controller.ts index dc84564b37..1e255bd18e 100644 --- a/api.planx.uk/modules/auth/controller.ts +++ b/api.planx.uk/modules/auth/controller.ts @@ -47,12 +47,12 @@ function setJWTCookie(returnTo: string, res: Response, req: Request) { new Date().setFullYear(new Date().getFullYear() + 1), ).getTime(), sameSite: "none", + secure: true, }; const httpOnlyCookieOptions: CookieOptions = { ...defaultCookieOptions, httpOnly: true, - secure: true, }; // Set secure, httpOnly cookie with JWT From f7110e9c5d61f868829e4f71e8ad0de0199015bf Mon Sep 17 00:00:00 2001 From: augustlindemer <118665588+augustlindemer@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:46:17 +0000 Subject: [PATCH 05/10] feat: Hook up granular A4 GIS API variables for Barnet (#2639) * Create barnet.ts * Update digitalLand.ts * feat: Hook up granular A4 GIS API variables for Barnet --- .../modules/gis/service/digitalLand.ts | 2 + .../local_authorities/metadata/barnet.ts | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 api.planx.uk/modules/gis/service/local_authorities/metadata/barnet.ts diff --git a/api.planx.uk/modules/gis/service/digitalLand.ts b/api.planx.uk/modules/gis/service/digitalLand.ts index 04387799f2..ea1f9cf8aa 100644 --- a/api.planx.uk/modules/gis/service/digitalLand.ts +++ b/api.planx.uk/modules/gis/service/digitalLand.ts @@ -18,6 +18,7 @@ export interface LocalAuthorityMetadata { } const localAuthorityMetadata: Record = { + barnet: require("./local_authorities/metadata/barnet"), birmingham: require("./local_authorities/metadata/birmingham"), buckinghamshire: require("./local_authorities/metadata/buckinghamshire"), camden: require("./local_authorities/metadata/camden"), @@ -203,6 +204,7 @@ async function go( // these are various ways we link source data to granular planx values (see local_authorities/metadata for specifics) entity.name.replace(/\r?\n|\r/g, " ") === a4s[key] || entity.reference === a4s[key] || + entity?.["article-4-direction"] === a4s[key] || entity?.notes === a4s[key] || entity?.description?.startsWith(a4s[key]) || formattedResult[key]?.value // if this granular var is already true, make sure it remains true diff --git a/api.planx.uk/modules/gis/service/local_authorities/metadata/barnet.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/barnet.ts new file mode 100644 index 0000000000..521e591ac8 --- /dev/null +++ b/api.planx.uk/modules/gis/service/local_authorities/metadata/barnet.ts @@ -0,0 +1,38 @@ +/* +LAD20CD: E09000003 +LAD20NM: Barnet +LAD20NMW: +FID: + +https://www.planning.data.gov.uk/entity/?dataset=article-4-direction-area&geometry_curie=statistical-geography%3AE09000003&entry_date_day=&entry_date_month=&entry_date_year= +https://docs.google.com/spreadsheets/d/1ZjqYdC7upA8YS9rBoyRIQPT1sqCXJBaxQDrvUh1todU/edit#gid=0 +*/ + +import { LocalAuthorityMetadata } from "../../digitalLand"; + +const planningConstraints: LocalAuthorityMetadata["planningConstraints"] = { + article4: { + // Planx granular values link to Digital Land article-4-direction and entity.reference + records: { + "article4.barnet.finchleyChurchEnd": "A4D1", + "article4.barnet.finchleyGardenVillage": "A4D2A1", + "article4.barnet.glenhillClose": "A4D3A1", + "article4.barnet.hendonBurroughs.1": "A4D5A1", + "article4.barnet.hendonBurroughs.2": "A4D5A2", + "article4.barnet.hampsteadGardenSuburb": "A4D4A1", + "article4.barnet.spaniardsEnd": " A4D4A2", + "article4.barnet.millHillA": "A4D6", + "article4.barnet.millHillB": "A4D7", + "article4.barnet.monkenHadleyA": "A4D8", + "article4.barnet.monkenHadleyB": "A4D9", + "article4.barnet.mossHall": "A4D10", + "article4.barnet.totteridgeA": "A4D11", + "article4.barnet.totteridgeB": "A4D12", + "article4.barnet.woodStreet": "A4D13", + "article4.barnet.hmo": "A4D14", + "article4.barnet.agriculturalLand": "A4D15", + }, + }, +}; + +export { planningConstraints }; From 736a91cf30a1b9811b3162b3b2dd0c76970d4391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 9 Jan 2024 12:04:26 +0000 Subject: [PATCH 06/10] fix: Setup `authMiddleware` for Apollo (#2638) * fix: Include credentials in Apollo requests * chore: Assign name to GetTeams query * chore: Make initUserStore() slightly clearer * feat: Setup auth middlware to check for token before making requests * fix: Cookies must be strings --- api.planx.uk/modules/auth/controller.ts | 3 +- editor.planx.uk/src/lib/graphql.ts | 45 ++++++++++++++++--- .../src/pages/FlowEditor/lib/store/user.ts | 4 +- editor.planx.uk/src/routes/authenticated.tsx | 2 +- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/api.planx.uk/modules/auth/controller.ts b/api.planx.uk/modules/auth/controller.ts index 1e255bd18e..458487ad6c 100644 --- a/api.planx.uk/modules/auth/controller.ts +++ b/api.planx.uk/modules/auth/controller.ts @@ -59,7 +59,8 @@ function setJWTCookie(returnTo: string, res: Response, req: Request) { res.cookie("jwt", req.user!.jwt, httpOnlyCookieOptions); // Set second cookie which can be read by browser to detect presence of the unreadable httpOnly cookie - res.cookie("auth", { loggedIn: true }, defaultCookieOptions); + const authCookie = btoa(JSON.stringify({ loggedIn: true })); + res.cookie("auth", authCookie, defaultCookieOptions); res.redirect(returnTo); } diff --git a/editor.planx.uk/src/lib/graphql.ts b/editor.planx.uk/src/lib/graphql.ts index 55ddba1af7..7c0a2aa4d6 100644 --- a/editor.planx.uk/src/lib/graphql.ts +++ b/editor.planx.uk/src/lib/graphql.ts @@ -7,14 +7,13 @@ import { Operation, } from "@apollo/client"; import { GraphQLErrors } from "@apollo/client/errors"; +import { setContext } from "@apollo/client/link/context"; import { onError } from "@apollo/client/link/error"; import { RetryLink } from "@apollo/client/link/retry"; import { logger } from "airbrake"; import { useStore } from "pages/FlowEditor/lib/store"; import { toast } from "react-toastify"; -import { getCookie } from "./cookie"; - const toastId = "error_toast"; // function used to verify response status @@ -38,9 +37,6 @@ const customFetch = async ( const authHttpLink = createHttpLink({ uri: process.env.REACT_APP_HASURA_URL, fetch: customFetch, - headers: { - authorization: `Bearer ${getCookie("jwt")}`, - }, }); const publicHttpLink = createHttpLink({ @@ -131,11 +127,48 @@ const retryLink = new RetryLink({ }, }); +/** + * Set auth header in Apollo client + * Must be done post-authentication once we have a value for JWT + */ +export const authMiddleware = setContext(async () => { + const jwt = await getJWT(); + + return { + headers: { + authorization: jwt ? `Bearer ${jwt}` : undefined, + }, + }; +}); + +/** + * Get the JWT from the store, and wait if not available + */ +const getJWT = async () => { + const jwt = useStore.getState().jwt; + if (jwt) return jwt; + + return await waitForAuthentication(); +}; + +/** + * Wait for authentication by subscribing to the JWT changes + */ +const waitForAuthentication = async () => + new Promise((resolve) => { + const unsubscribe = useStore.subscribe(({ jwt }) => { + if (jwt) { + unsubscribe(); + resolve(jwt); + } + }); + }); + /** * Client used to make all requests by authorised users */ export const client = new ApolloClient({ - link: from([retryLink, errorLink, authHttpLink]), + link: from([retryLink, errorLink, authMiddleware, authHttpLink]), cache: new InMemoryCache(), }); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts index dcb11681f2..10b0f3e00a 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts @@ -34,8 +34,8 @@ export const userStore: StateCreator = ( async initUserStore() { const { getUser, setUser } = get(); - - if (getUser()) return; + const currentUser = getUser(); + if (currentUser) return; const user = await getLoggedInUser(); setUser(user); diff --git a/editor.planx.uk/src/routes/authenticated.tsx b/editor.planx.uk/src/routes/authenticated.tsx index d8a7966536..53bd9c12dd 100644 --- a/editor.planx.uk/src/routes/authenticated.tsx +++ b/editor.planx.uk/src/routes/authenticated.tsx @@ -16,7 +16,7 @@ const editorRoutes = compose( "/": route(async () => { const { data } = await client.query({ query: gql` - query { + query GetTeams { teams(order_by: { name: asc }) { id name From b5383a264c1744a3a7e5bbc2b94ada64516bc6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 9 Jan 2024 14:47:12 +0000 Subject: [PATCH 07/10] Revert "chore: Temp skip of e2e tests [skip pizza] (#2589)" (#2642) This reverts commit 1c90acba3f46bf2f508cabe796c375a3f39921a9. --- e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts index c9b27078d7..bb8b3aba97 100644 --- a/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts +++ b/e2e/tests/ui-driven/src/create-flow/create-flow.spec.ts @@ -1,5 +1,3 @@ -/* eslint-disable */ - import { test, expect, Browser } from "@playwright/test"; import { contextDefaults, @@ -14,7 +12,7 @@ import { import type { Context } from "../context"; import { getTeamPage, isGetUserRequest } from "./helpers"; -test.skip("Navigation", () => { +test.describe("Navigation", () => { let context: Context = { ...contextDefaults, }; From 9320e60a239838fe5b6d0ac191f5a7c10f441b2d Mon Sep 17 00:00:00 2001 From: Mike <36415632+Mike-Heneghan@users.noreply.github.com> Date: Tue, 9 Jan 2024 15:14:07 +0000 Subject: [PATCH 08/10] feat: expand tracking of "change" clicks in analytics (#2601) * feat: update instances of `changeAnswer` being called to ensure it's tracked * feat: track change clicks on PropertyInformation component * refactor: use more comprehensive node title extraction method * feat: store target node id when user clicks change or back where possible --- .../components/PropertyInformation/Public.tsx | 10 +++++++- .../components/Result/Public/ResultReason.tsx | 4 ++-- .../src/@planx/components/Section/Public.tsx | 8 ++++++- .../components/shared/Preview/SummaryList.tsx | 13 +++++++--- .../FlowEditor/lib/analyticsProvider.tsx | 24 ++++++++++--------- .../src/pages/Preview/Questions.tsx | 4 ++-- 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx b/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx index 5bd3bb2982..761515830d 100644 --- a/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx +++ b/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx @@ -11,6 +11,7 @@ import { useFormik } from "formik"; import { submitFeedback } from "lib/feedback"; import { publicClient } from "lib/graphql"; import find from "lodash/find"; +import { useAnalyticsTracking } from "pages/FlowEditor/lib/analyticsProvider"; import { useStore } from "pages/FlowEditor/lib/store"; import { handleSubmit } from "pages/Preview/Node"; import React from "react"; @@ -237,6 +238,13 @@ function PropertyDetails(props: PropertyDetailsProps) { const { data, showPropertyTypeOverride, overrideAnswer } = props; const filteredData = data.filter((d) => Boolean(d.detail)); + const { trackBackwardsNavigation } = useAnalyticsTracking(); + + const handleOverrideAnswer = (fn: string) => { + trackBackwardsNavigation("change"); + overrideAnswer(fn); + }; + return ( {filteredData.map(({ heading, detail, fn }: PropertyDetail) => ( @@ -251,7 +259,7 @@ function PropertyDetails(props: PropertyDetailsProps) { onClick={(event) => { event.stopPropagation(); // Specify the passport key (eg data.fn, data.val) that should be overwritten - overrideAnswer(fn); + handleOverrideAnswer(fn); }} > Change diff --git a/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx b/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx index 6053671499..e7521ec41d 100644 --- a/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx +++ b/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx @@ -141,10 +141,10 @@ const ResultReason: React.FC = ({ : "" }`; - const { trackBackwardsNavigationByNodeId } = useAnalyticsTracking(); + const { trackBackwardsNavigation } = useAnalyticsTracking(); const handleChangeAnswer = (id: string) => { - trackBackwardsNavigationByNodeId(id, "change"); + trackBackwardsNavigation("change", id); changeAnswer(id); }; diff --git a/editor.planx.uk/src/@planx/components/Section/Public.tsx b/editor.planx.uk/src/@planx/components/Section/Public.tsx index 3e30991f9a..3eb8d22654 100644 --- a/editor.planx.uk/src/@planx/components/Section/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Section/Public.tsx @@ -5,6 +5,7 @@ import Typography from "@mui/material/Typography"; import visuallyHidden from "@mui/utils/visuallyHidden"; import Tag, { TagType } from "@planx/components/shared/Buttons/Tag"; import type { PublicProps } from "@planx/components/ui"; +import { useAnalyticsTracking } from "pages/FlowEditor/lib/analyticsProvider"; import { Store, useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { SectionNode, SectionStatus } from "types"; @@ -134,11 +135,16 @@ export function SectionsOverviewList({ alteredSectionIds, }); + const { trackBackwardsNavigation } = useAnalyticsTracking(); + const changeFirstAnswerInSection = (sectionId: string) => { const sectionIndex = flow._root.edges?.indexOf(sectionId); if (sectionIndex !== undefined) { const firstNodeInSection = flow._root.edges?.[sectionIndex + 1]; - if (firstNodeInSection) changeAnswer(firstNodeInSection); + if (firstNodeInSection) { + trackBackwardsNavigation("change", firstNodeInSection); + changeAnswer(firstNodeInSection); + } } }; diff --git a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx index 98bbaa411a..9f16e806a0 100644 --- a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx @@ -113,6 +113,8 @@ function SummaryListsBySections(props: SummaryListsBySectionsProps) { state.getSortedBreadcrumbsBySection, ]); + const { trackBackwardsNavigation } = useAnalyticsTracking(); + const isValidComponent = ([nodeId, userData]: BreadcrumbEntry) => { const node = props.flow[nodeId]; const doesNodeExist = Boolean(props.flow[nodeId]); @@ -157,6 +159,11 @@ function SummaryListsBySections(props: SummaryListsBySectionsProps) { .map(removeNonPresentationalNodes) .map((section) => section.map(makeSummaryBreadcrumb)); + const handleChangeAnswer = (id: string) => { + trackBackwardsNavigation("change", id); + props.changeAnswer(id); + }; + return ( <> {sectionsWithFilteredBreadcrumbs.map( @@ -178,7 +185,7 @@ function SummaryListsBySections(props: SummaryListsBySectionsProps) { - props.changeAnswer(filteredBreadcrumbs[0].nodeId) + handleChangeAnswer(filteredBreadcrumbs[0].nodeId) } component="button" fontSize="body1.fontSize" @@ -220,10 +227,10 @@ function SummaryListsBySections(props: SummaryListsBySectionsProps) { // For applicable component types, display a list of their question & answers with a "change" link // ref https://design-system.service.gov.uk/components/summary-list/ function SummaryList(props: SummaryListProps) { - const { trackBackwardsNavigationByNodeId } = useAnalyticsTracking(); + const { trackBackwardsNavigation } = useAnalyticsTracking(); const handleChangeAnswer = (id: string) => { - trackBackwardsNavigationByNodeId(id, "change"); + trackBackwardsNavigation("change", id); props.changeAnswer(id); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx b/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx index a24255dfe8..1b23fe861a 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx @@ -33,6 +33,7 @@ type NodeMetadata = { flag?: Flag; title?: string; type?: TYPES; + id?: string; isAutoAnswered?: boolean; }; @@ -45,9 +46,9 @@ const analyticsContext = createContext<{ trackFlowDirectionChange: ( flowDirection: AnalyticsLogDirection, ) => Promise; - trackBackwardsNavigationByNodeId: ( - nodeId: string, + trackBackwardsNavigation: ( backwardsNavigationType: BackwardsNavigationInitiatorType, + nodeId?: string, ) => Promise; node: Store.node | null; trackInputErrors: (error: string) => Promise; @@ -61,7 +62,7 @@ const analyticsContext = createContext<{ trackHelpClick: () => Promise.resolve(), trackNextStepsLinkClick: () => Promise.resolve(), trackFlowDirectionChange: () => Promise.resolve(), - trackBackwardsNavigationByNodeId: () => Promise.resolve(), + trackBackwardsNavigation: () => Promise.resolve(), node: null, trackInputErrors: () => Promise.resolve(), track: () => Promise.resolve(), @@ -141,7 +142,7 @@ export const AnalyticsProvider: React.FC<{ children: React.ReactNode }> = ({ trackHelpClick, trackNextStepsLinkClick, trackFlowDirectionChange, - trackBackwardsNavigationByNodeId, + trackBackwardsNavigation, node, trackInputErrors, track, @@ -340,11 +341,11 @@ export const AnalyticsProvider: React.FC<{ children: React.ReactNode }> = ({ } } - async function trackBackwardsNavigationByNodeId( - nodeId: string, + async function trackBackwardsNavigation( initiator: BackwardsNavigationInitiatorType, + nodeId?: string, ) { - const targetNodeMetadata = getTitleAndTypeFromFlow(nodeId); + const targetNodeMetadata = nodeId ? getTargetNodeDataFromFlow(nodeId) : {}; const metadata: Record = {}; metadata[`${initiator}`] = targetNodeMetadata; @@ -431,11 +432,12 @@ export const AnalyticsProvider: React.FC<{ children: React.ReactNode }> = ({ } } - function getTitleAndTypeFromFlow(nodeId: string) { - const { data, type } = flow[nodeId]; + function getTargetNodeDataFromFlow(nodeId: string) { + const node = flow[nodeId]; const nodeMetadata: NodeMetadata = { - title: data?.text, - type: type, + title: extractNodeTitle(node), + type: node.type, + id: nodeId, }; return nodeMetadata; } diff --git a/editor.planx.uk/src/pages/Preview/Questions.tsx b/editor.planx.uk/src/pages/Preview/Questions.tsx index 495c610bd6..58b1dafaf7 100644 --- a/editor.planx.uk/src/pages/Preview/Questions.tsx +++ b/editor.planx.uk/src/pages/Preview/Questions.tsx @@ -77,7 +77,7 @@ const Questions = ({ previewEnvironment }: QuestionsProps) => { state.getType, ]); const isStandalone = previewEnvironment === "standalone"; - const { createAnalytics, node, trackBackwardsNavigationByNodeId } = + const { createAnalytics, node, trackBackwardsNavigation } = useAnalyticsTracking(); const [gotFlow, setGotFlow] = useState(false); const isUsingLocalStorage = @@ -151,7 +151,7 @@ const Questions = ({ previewEnvironment }: QuestionsProps) => { const goBack = useCallback(() => { const previous = previousCard(node); if (previous) { - trackBackwardsNavigationByNodeId(previous, "back"); + trackBackwardsNavigation("back", previous); record(previous); } }, [node?.id]); From 8b41972aef2e51c7356e80e85c6c3a55c3fe6f5a Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 9 Jan 2024 15:50:35 +0000 Subject: [PATCH 09/10] feat: hook up staging GOV.UK Pay for Medway (#2640) --- .env.example | 9 ++++++++ api.planx.uk/.env.test.example | 21 ++++++++++++++----- api.planx.uk/server.ts | 1 + docker-compose.yml | 2 ++ .../application/Pulumi.staging.yaml | 2 ++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index b7f1713f3b..3169fdbe27 100644 --- a/.env.example +++ b/.env.example @@ -99,5 +99,14 @@ UNIFORM_CLIENT_AYLESBURY_VALE=👻 UNIFORM_CLIENT_CHILTERN=👻 UNIFORM_CLIENT_WYCOMBE=👻 +## Camden +BOPS_SUBMISSION_URL_CAMDEN=👻 + +## Gloucester +BOPS_SUBMISSION_URL_GLOUCESTER=👻 + +## Medway +GOV_UK_PAY_TOKEN_MEDWAY=👻 + ## End-to-end test team (borrows Lambeth's details) GOV_UK_PAY_TOKEN_E2E=👻 diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index 51ce761586..877b1405bf 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -26,10 +26,7 @@ HASURA_PLANX_API_KEY=👻 # Integrations BOPS_API_TOKEN=👻 - -GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=👻 -GOV_UK_PAY_TOKEN_LAMBETH=👻 -GOV_UK_PAY_TOKEN_SOUTHWARK=👻 +BOPS_API_TOKEN=👻 GOVUK_NOTIFY_API_KEY=testlocaldev2-👻 @@ -38,12 +35,26 @@ UNIFORM_SUBMISSION_URL=👻 SLACK_WEBHOOK_URL=👻 +ORDNANCE_SURVEY_API_KEY=👻 + # Local authority specific integrations ## Lambeth GOV_UK_PAY_TOKEN_LAMBETH=👻 +BOPS_SUBMISSION_URL_LAMBETH=👻 ## Southwark GOV_UK_PAY_TOKEN_SOUTHWARK=👻 +BOPS_SUBMISSION_URL_SOUTHWARK=👻 ## Buckinghamshire -GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=👻 \ No newline at end of file +GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=👻 +BOPS_SUBMISSION_URL_BUCKINGHAMSHIRE=👻 + +## Camden +BOPS_SUBMISSION_URL_CAMDEN=👻 + +## Gloucester +BOPS_SUBMISSION_URL_GLOUCESTER=👻 + +## Medway +GOV_UK_PAY_TOKEN_MEDWAY=👻 diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 0285331c74..0016cd8659 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -89,6 +89,7 @@ assert(process.env.BOPS_API_TOKEN); assert(process.env.UNIFORM_TOKEN_URL); assert(process.env.UNIFORM_SUBMISSION_URL); +// Medway has sandbox pay only, so skip assertion as this will fail in production ["BUCKINGHAMSHIRE", "LAMBETH", "SOUTHWARK"].forEach((authority) => { assert(process.env[`GOV_UK_PAY_TOKEN_${authority}`]); }); diff --git a/docker-compose.yml b/docker-compose.yml index abc75db0af..25fc9bccee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -153,6 +153,8 @@ services: UNIFORM_CLIENT_AYLESBURY_VALE: ${UNIFORM_CLIENT_AYLESBURY_VALE} UNIFORM_CLIENT_CHILTERN: ${UNIFORM_CLIENT_CHILTERN} UNIFORM_CLIENT_WYCOMBE: ${UNIFORM_CLIENT_WYCOMBE} + #Medway + GOV_UK_PAY_TOKEN_MEDWAY: ${GOV_UK_PAY_TOKEN_MEDWAY} sharedb: restart: unless-stopped diff --git a/infrastructure/application/Pulumi.staging.yaml b/infrastructure/application/Pulumi.staging.yaml index 704a18bfb8..785376f7be 100644 --- a/infrastructure/application/Pulumi.staging.yaml +++ b/infrastructure/application/Pulumi.staging.yaml @@ -18,6 +18,8 @@ config: secure: AAABADroqKJ1/CanxoghKyCutFA8bmiPBuafrNYGNMn1H16jXiHuytHUUByTbXZZHtANciv7rkQEJosUmyay5j/ZFKu9TeS2WaIGBD913EVlv4iXDw3Y5OU2bSocROlYQm7/ application:gov-uk-pay-token-lambeth: secure: AAABAPy5USkd8/hwq6vFXP45BXsYFUltR6gj8PoiZkOLRPUd1wgQ3Yhgc1Cyn+lb5cZrXBoVPjuVhm/UvBN82DNzRTl2TxAakCQQIrBU5xil+m9UnbY82CNSMDuEaWwMpR3C + application:gov-uk-pay-token-medway: + secure: AAABAOf9pQgmUkPWbyBQpkd2eZDtzx8WhHfPMD+V8lDLP/hqo24ZZyCrDfq6VBrcEeZVL89dvJ/PIVng9V5xFDKwpRcChTsOsen6epWGE/I0zwDdwrONmxgbPXnGgxLDtiEp application:gov-uk-pay-token-southwark: secure: AAABALGCrA9ZqRLql+ZHRQD/q6GiGNihtdgPL/7k5d37vgjqW115YR30HG9ofE00qP2Hkr2ZkYkJhVCIr9G5l1wSGXBNI+ldXxTCU8PWLGWv+Xa+Sv5Ltgd9egmwBBqUgvwe application:govuk-notify-api-key: From 9763e76b7e2d7dc3f0de618d81549ed5b5d5f997 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 9 Jan 2024 15:58:39 +0000 Subject: [PATCH 10/10] feat: create parallel BOPS send events for v1 & v2 on all environments (#2644) --- api.planx.uk/modules/send/bops/bops.ts | 19 ++++++++++------- .../send/createSendEvents/controller.ts | 21 ++++++++----------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/api.planx.uk/modules/send/bops/bops.ts b/api.planx.uk/modules/send/bops/bops.ts index 9bfed5c288..1dd5006f9e 100644 --- a/api.planx.uk/modules/send/bops/bops.ts +++ b/api.planx.uk/modules/send/bops/bops.ts @@ -261,16 +261,16 @@ const sendToBOPSV2 = async ( .catch((error) => { if (error.response) { throw new Error( - `Sending to BOPS v2 failed (${localAuthority}):\n${JSON.stringify( - error.response.data, - null, - 2, - )}`, + `Sending to BOPS v2 failed (${[localAuthority, payload?.sessionId] + .filter(Boolean) + .join(" - ")}):\n${JSON.stringify(error.response.data, null, 2)}`, ); } else { // re-throw other errors throw new Error( - `Sending to BOPS v2 failed (${localAuthority}):\n${error}`, + `Sending to BOPS v2 failed (${[localAuthority, payload?.sessionId] + .filter(Boolean) + .join(" - ")}):\n${error}`, ); } }); @@ -279,7 +279,12 @@ const sendToBOPSV2 = async ( next( new ServerError({ status: 500, - message: `Sending to BOPS v2 failed (${localAuthority})`, + message: `Sending to BOPS v2 failed (${[ + localAuthority, + payload?.sessionId, + ] + .filter(Boolean) + .join(" - ")})`, cause: err, }), ); diff --git a/api.planx.uk/modules/send/createSendEvents/controller.ts b/api.planx.uk/modules/send/createSendEvents/controller.ts index 6fa8b60808..684bbbcfaa 100644 --- a/api.planx.uk/modules/send/createSendEvents/controller.ts +++ b/api.planx.uk/modules/send/createSendEvents/controller.ts @@ -30,28 +30,25 @@ const createSendEvents: CreateSendEventsController = async ( if (bops) { const bopsEvent = await createScheduledEvent({ webhook: `{{HASURA_PLANX_API_URL}}/bops/${bops.localAuthority}`, - schedule_at: new Date(now.getTime() + 30 * 1000), + schedule_at: new Date(now.getTime() + 25 * 1000), payload: bops.body, comment: `bops_submission_${sessionId}`, }); combinedResponse["bops"] = bopsEvent; - const isProduction = process.env.APP_ENVIRONMENT === "production"; - if (!isProduction) { - const bopsV2Event = await createScheduledEvent({ - webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${bops.localAuthority}`, - schedule_at: new Date(now.getTime() + 45 * 1000), - payload: bops.body, - comment: `bops_v2_submission_${sessionId}`, - }); - combinedResponse["bops_v2"] = bopsV2Event; - } + const bopsV2Event = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${bops.localAuthority}`, + schedule_at: new Date(now.getTime() + 50 * 1000), + payload: bops.body, + comment: `bops_v2_submission_${sessionId}`, + }); + combinedResponse["bops_v2"] = bopsV2Event; } if (uniform) { const uniformEvent = await createScheduledEvent({ webhook: `{{HASURA_PLANX_API_URL}}/uniform/${uniform.localAuthority}`, - schedule_at: new Date(now.getTime() + 60 * 1000), + schedule_at: new Date(now.getTime() + 75 * 1000), payload: uniform.body, comment: `uniform_submission_${sessionId}`, });