From 86d4e8952ac7d0c497685ed7de6ae1bcbe902eba Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 11 Nov 2024 00:19:04 -0500 Subject: [PATCH 01/16] Add recaptcha validation to join form --- package.json | 2 + src/components/JoinForm/JoinForm.module.scss | 12 +++ src/components/JoinForm/JoinForm.tsx | 86 ++++++++++++++++++- .../JoinForm/hide_recaptcha_badge.css | 3 + 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/components/JoinForm/hide_recaptcha_badge.css diff --git a/package.json b/package.json index 50eb9b9..fc69da4 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-error-boundary": "^4.0.12", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.0", "react-phone-number-input": "^3.3.9", "react-select": "^5.8.0", @@ -39,6 +40,7 @@ "devDependencies": { "@playwright/test": "^1.45.3", "@types/react": "^18.2.48", + "@types/react-google-recaptcha": "^2.1.9", "dotenv": "^16.4.5" } } diff --git a/src/components/JoinForm/JoinForm.module.scss b/src/components/JoinForm/JoinForm.module.scss index 03b0fca..1c4e039 100644 --- a/src/components/JoinForm/JoinForm.module.scss +++ b/src/components/JoinForm/JoinForm.module.scss @@ -105,3 +105,15 @@ .hidden { display: none; } + +.captchaDisclaimer { + font-size: 80%; + color: #9a9a9a; + margin-top: 30px; + margin-bottom: 30px; +} + +.captchaDisclaimer > a { + color: #000; + margin-left: 3px; +} diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index 1ceaa40..ac76ed7 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import styles from "./JoinForm.module.scss"; +import "./hide_recaptcha_badge.css"; import { parsePhoneNumberFromString } from "libphonenumber-js"; import { CircularProgress, @@ -19,6 +20,7 @@ import InfoConfirmationDialog from "../InfoConfirmation/InfoConfirmation"; import { JoinRecord } from "@/lib/types"; import { useTranslations } from "next-intl"; import LocaleSwitcher from "../LocaleSwitcher"; +import ReCAPTCHA from "react-google-recaptcha"; export class JoinFormValues { constructor( @@ -35,6 +37,8 @@ export class JoinFormValues { public referral: string = "", public ncl: boolean = false, public trust_me_bro: boolean = false, + public recaptcha_invisible_token: string | null = null, + public recaptcha_checkbox_token: string | null = null, ) {} } @@ -77,7 +81,12 @@ export default function JoinForm() { defaultValues: defaultFormValues, }); + const recaptchaV3Ref = React.useRef(null); + const recaptchaV2Ref = React.useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [isProbablyABot, setIsProbablyABot] = useState(false); + const [checkBoxCaptchaToken, setCheckBoxCaptchaToken] = useState(""); const [isInfoConfirmationDialogueOpen, setIsInfoConfirmationDialogueOpen] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); @@ -149,6 +158,22 @@ export default function JoinForm() { }; async function submitJoinFormToMeshDB(joinFormSubmission: JoinFormValues) { + // Get the captcha tokens and add them to request object. + // Per the google docs, the implicit token must be retrieved on form submission, + // so that the token doesn't expire before server side validation + if (checkBoxCaptchaToken) { + joinFormSubmission.recaptcha_checkbox_token = checkBoxCaptchaToken; + } else { + joinFormSubmission.recaptcha_checkbox_token = null; + } + if (!recaptchaV3Ref.current) { + throw Error( + "Invalid recaptcha Ref when trying to execute() on v3 captcha, is something broken with page render?", + ); + } + joinFormSubmission.recaptcha_invisible_token = + await recaptchaV3Ref.current.executeAsync(); + // Before we try anything else, submit to S3 for safety. let record: JoinRecord = Object.assign( structuredClone(joinFormSubmission), @@ -215,6 +240,7 @@ export default function JoinForm() { console.debug("Join Form submitted successfully"); setIsLoading(false); setIsSubmitted(true); + setIsProbablyABot(false); return; } @@ -250,6 +276,26 @@ export default function JoinForm() { return; } + // If the server said the recaptcha token indicates this was a bot (HTTP 401), prompt the user with the + // interactive "checkbox" V2 captcha. However, if they have already submitted a checkbox captcha + // and are still seeing a 401, something has gone wrong - fall back to the generic 4xx error handling logic below + if ( + record.code == 401 && + !joinFormSubmission.recaptcha_checkbox_token + ) { + toast.warning( + "Please complete an additional verification step to confirm your submission", + ); + setIsProbablyABot(true); + setIsSubmitted(false); + setIsLoading(false); + + console.error( + "Request failed invisible captcha verification, user can try again with checkbox validation", + ); + return; + } + if (record.code !== null && 500 <= record.code && record.code <= 599) { // If it was the server's fault, then just accept the record and move // on. @@ -269,6 +315,13 @@ export default function JoinForm() { toast.error(`Could not submit Join Form: ${detail}`); console.error(`An error occurred: ${detail}`); setIsLoading(false); + + // Clear the checkbox captcha if it exists, to allow the user to retry if needed + if (recaptchaV2Ref.current) { + recaptchaV2Ref.current.reset(); + setCheckBoxCaptchaToken(""); + } + return; } @@ -434,6 +487,25 @@ export default function JoinForm() { ), })} + {/* This first captcha isn't actually displayed, it just silently collects user metrics and generates a token */} + + {/* This second captcha is the traditional "I'm not a robot" checkbox, + only shown if the user gets 401'ed due to a low score on the above captcha */} + {isProbablyABot ? ( + setCheckBoxCaptchaToken(newToken ?? "")} + /> + ) : ( + <> + )} {/*

State Debugger

@@ -447,7 +519,11 @@ export default function JoinForm() {
+
+ This site is protected by reCAPTCHA and the Google + Privacy Policy and + + Terms of Service + {" "} + apply. +
diff --git a/src/components/JoinForm/hide_recaptcha_badge.css b/src/components/JoinForm/hide_recaptcha_badge.css new file mode 100644 index 0000000..545f6b0 --- /dev/null +++ b/src/components/JoinForm/hide_recaptcha_badge.css @@ -0,0 +1,3 @@ +.grecaptcha-badge { + visibility: hidden; +} From 1b84cc72096a60dabe0fd91c8b5b0d5803a1c96b Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Tue, 19 Nov 2024 13:41:43 -0500 Subject: [PATCH 02/16] Move tokens to HTTP headers --- src/components/JoinForm/JoinForm.tsx | 35 ++++++++++++---------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index ac76ed7..46ff767 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -37,8 +37,6 @@ export class JoinFormValues { public referral: string = "", public ncl: boolean = false, public trust_me_bro: boolean = false, - public recaptcha_invisible_token: string | null = null, - public recaptcha_checkbox_token: string | null = null, ) {} } @@ -158,22 +156,6 @@ export default function JoinForm() { }; async function submitJoinFormToMeshDB(joinFormSubmission: JoinFormValues) { - // Get the captcha tokens and add them to request object. - // Per the google docs, the implicit token must be retrieved on form submission, - // so that the token doesn't expire before server side validation - if (checkBoxCaptchaToken) { - joinFormSubmission.recaptcha_checkbox_token = checkBoxCaptchaToken; - } else { - joinFormSubmission.recaptcha_checkbox_token = null; - } - if (!recaptchaV3Ref.current) { - throw Error( - "Invalid recaptcha Ref when trying to execute() on v3 captcha, is something broken with page render?", - ); - } - joinFormSubmission.recaptcha_invisible_token = - await recaptchaV3Ref.current.executeAsync(); - // Before we try anything else, submit to S3 for safety. let record: JoinRecord = Object.assign( structuredClone(joinFormSubmission), @@ -200,6 +182,16 @@ export default function JoinForm() { ); } + // Get the v3 captcha token. Per the google docs, the implicit token must be retrieved on form submission, + // so that the token doesn't expire before server side validation + if (!recaptchaV3Ref?.current) { + throw Error( + "Invalid recaptcha Ref when trying to execute() on v3 captcha, is something broken with page render?", + ); + } + const recaptchaInvisibleToken = await recaptchaV3Ref.current.executeAsync(); + console.log(recaptchaInvisibleToken); + // Attempt to submit the Join Form try { const response = await fetch( @@ -207,6 +199,10 @@ export default function JoinForm() { { method: "POST", body: JSON.stringify(joinFormSubmission), + headers: { + "X-Recaptcha-V2-Token": checkBoxCaptchaToken ? checkBoxCaptchaToken : "", + "X-Recaptcha-V3-Token": recaptchaInvisibleToken ? recaptchaInvisibleToken : "", + } }, ); const j = await response.json(); @@ -280,8 +276,7 @@ export default function JoinForm() { // interactive "checkbox" V2 captcha. However, if they have already submitted a checkbox captcha // and are still seeing a 401, something has gone wrong - fall back to the generic 4xx error handling logic below if ( - record.code == 401 && - !joinFormSubmission.recaptcha_checkbox_token + record.code == 401 && !checkBoxCaptchaToken ) { toast.warning( "Please complete an additional verification step to confirm your submission", From 8b86b0b9ece84f8628902f97d71df75807e749b0 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Tue, 19 Nov 2024 17:30:00 -0500 Subject: [PATCH 03/16] Pull captcha keys from env vars --- .env.sample | 3 +++ package-lock.json | 38 ++++++++++++++++++++++++++++ src/components/JoinForm/JoinForm.tsx | 30 ++++++++++++++++------ src/lib/endpoint.ts | 5 ++++ 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/.env.sample b/.env.sample index dbec4f4..2f87b77 100644 --- a/.env.sample +++ b/.env.sample @@ -15,3 +15,6 @@ COMPOSE_EXTERNAL_NETWORK=false # Set this to traefik-net in prod COMPOSE_NETWORK_NAME= +# Not required for local dev, unless the backend is configured to require captchas +RECAPTCHA_V2_KEY= +RECAPTCHA_V3_KEY= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index dcbc040..7fe2a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-error-boundary": "^4.0.12", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.0", "react-phone-number-input": "^3.3.9", "react-select": "^5.8.0", @@ -38,6 +39,7 @@ "devDependencies": { "@playwright/test": "^1.45.3", "@types/react": "^18.2.48", + "@types/react-google-recaptcha": "^2.1.9", "dotenv": "^16.4.5" } }, @@ -2605,6 +2607,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -4106,6 +4118,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -4129,6 +4154,19 @@ "react": ">=16.13.1" } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-hook-form": { "version": "7.53.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index 46ff767..83e4f61 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -15,7 +15,7 @@ import { import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { saveJoinRecordToS3 } from "@/lib/join_record"; -import { getMeshDBAPIEndpoint } from "@/lib/endpoint"; +import {getMeshDBAPIEndpoint, getRecaptchaKeys} from "@/lib/endpoint"; import InfoConfirmationDialog from "../InfoConfirmation/InfoConfirmation"; import { JoinRecord } from "@/lib/types"; import { useTranslations } from "next-intl"; @@ -91,9 +91,21 @@ export default function JoinForm() { const [isMeshDBProbablyDown, setIsMeshDBProbablyDown] = useState(false); const [isBadPhoneNumber, setIsBadPhoneNumber] = useState(false); const [joinRecordKey, setJoinRecordKey] = useState(""); + + const [recaptchaV2Key, setRecaptchaV2Key] = useState(undefined); + const [recaptchaV3Key, setRecaptchaV3Key] =useState(undefined); const isBeta = true; + + useEffect(() => { + (async () => { + const [v2_key, v3_key] = await getRecaptchaKeys(); + setRecaptchaV2Key(v2_key); + setRecaptchaV3Key(v3_key); + })() + }, [setRecaptchaV2Key, setRecaptchaV3Key]); + // Store the values submitted by the user or returned by the server const [infoToConfirm, setInfoToConfirm] = useState>([ { key: "" as keyof JoinFormValues, original: "", new: "" }, @@ -483,19 +495,21 @@ export default function JoinForm() { })} {/* This first captcha isn't actually displayed, it just silently collects user metrics and generates a token */} - + { recaptchaV3Key ? + : <> + } {/* This second captcha is the traditional "I'm not a robot" checkbox, only shown if the user gets 401'ed due to a low score on the above captcha */} - {isProbablyABot ? ( + {isProbablyABot && recaptchaV2Key ? ( setCheckBoxCaptchaToken(newToken ?? "")} /> ) : ( diff --git a/src/lib/endpoint.ts b/src/lib/endpoint.ts index 721e220..75e62ce 100644 --- a/src/lib/endpoint.ts +++ b/src/lib/endpoint.ts @@ -7,3 +7,8 @@ export async function getMeshDBAPIEndpoint() { } return process.env.MESHDB_URL; } + +// Literally just ask the server what captcha keys to use +export async function getRecaptchaKeys() { + return [process.env.RECAPTCHA_V2_KEY, process.env.RECAPTCHA_V3_KEY]; +} From f0c16be2fb3eaac0bc230f038deb5a0e66d8d07b Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Tue, 19 Nov 2024 17:36:56 -0500 Subject: [PATCH 04/16] Don't require captcha v3 --- src/components/JoinForm/JoinForm.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index 83e4f61..8285760 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -196,13 +196,12 @@ export default function JoinForm() { // Get the v3 captcha token. Per the google docs, the implicit token must be retrieved on form submission, // so that the token doesn't expire before server side validation - if (!recaptchaV3Ref?.current) { - throw Error( - "Invalid recaptcha Ref when trying to execute() on v3 captcha, is something broken with page render?", - ); + let recaptchaInvisibleToken = ""; + if (recaptchaV3Ref?.current) { + recaptchaInvisibleToken = await recaptchaV3Ref.current.executeAsync() ?? ""; + } else { + console.warn("No ref found for the recaptchaV3 component, not including captcha token in HTTP request") } - const recaptchaInvisibleToken = await recaptchaV3Ref.current.executeAsync(); - console.log(recaptchaInvisibleToken); // Attempt to submit the Join Form try { From 1517c132b538af80f800121579f22e5394d2be1c Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Tue, 19 Nov 2024 17:38:22 -0500 Subject: [PATCH 05/16] Move captcha fetch inside of try-catch --- src/components/JoinForm/JoinForm.tsx | 58 ++++++++++++++++------------ 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index 8285760..f977ca2 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -15,7 +15,7 @@ import { import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { saveJoinRecordToS3 } from "@/lib/join_record"; -import {getMeshDBAPIEndpoint, getRecaptchaKeys} from "@/lib/endpoint"; +import { getMeshDBAPIEndpoint, getRecaptchaKeys } from "@/lib/endpoint"; import InfoConfirmationDialog from "../InfoConfirmation/InfoConfirmation"; import { JoinRecord } from "@/lib/types"; import { useTranslations } from "next-intl"; @@ -91,19 +91,22 @@ export default function JoinForm() { const [isMeshDBProbablyDown, setIsMeshDBProbablyDown] = useState(false); const [isBadPhoneNumber, setIsBadPhoneNumber] = useState(false); const [joinRecordKey, setJoinRecordKey] = useState(""); - - const [recaptchaV2Key, setRecaptchaV2Key] = useState(undefined); - const [recaptchaV3Key, setRecaptchaV3Key] =useState(undefined); - const isBeta = true; + const [recaptchaV2Key, setRecaptchaV2Key] = useState( + undefined, + ); + const [recaptchaV3Key, setRecaptchaV3Key] = useState( + undefined, + ); + const isBeta = true; useEffect(() => { (async () => { const [v2_key, v3_key] = await getRecaptchaKeys(); setRecaptchaV2Key(v2_key); setRecaptchaV3Key(v3_key); - })() + })(); }, [setRecaptchaV2Key, setRecaptchaV3Key]); // Store the values submitted by the user or returned by the server @@ -194,26 +197,33 @@ export default function JoinForm() { ); } - // Get the v3 captcha token. Per the google docs, the implicit token must be retrieved on form submission, - // so that the token doesn't expire before server side validation - let recaptchaInvisibleToken = ""; - if (recaptchaV3Ref?.current) { - recaptchaInvisibleToken = await recaptchaV3Ref.current.executeAsync() ?? ""; - } else { - console.warn("No ref found for the recaptchaV3 component, not including captcha token in HTTP request") - } - // Attempt to submit the Join Form try { + // Get the v3 captcha token. Per the google docs, the implicit token must be retrieved on form submission, + // so that the token doesn't expire before server side validation + let recaptchaInvisibleToken = ""; + if (recaptchaV3Ref?.current) { + recaptchaInvisibleToken = + (await recaptchaV3Ref.current.executeAsync()) ?? ""; + } else { + console.warn( + "No ref found for the recaptchaV3 component, not including captcha token in HTTP request", + ); + } + const response = await fetch( `${await getMeshDBAPIEndpoint()}/api/v1/join/`, { method: "POST", body: JSON.stringify(joinFormSubmission), headers: { - "X-Recaptcha-V2-Token": checkBoxCaptchaToken ? checkBoxCaptchaToken : "", - "X-Recaptcha-V3-Token": recaptchaInvisibleToken ? recaptchaInvisibleToken : "", - } + "X-Recaptcha-V2-Token": checkBoxCaptchaToken + ? checkBoxCaptchaToken + : "", + "X-Recaptcha-V3-Token": recaptchaInvisibleToken + ? recaptchaInvisibleToken + : "", + }, }, ); const j = await response.json(); @@ -286,9 +296,7 @@ export default function JoinForm() { // If the server said the recaptcha token indicates this was a bot (HTTP 401), prompt the user with the // interactive "checkbox" V2 captcha. However, if they have already submitted a checkbox captcha // and are still seeing a 401, something has gone wrong - fall back to the generic 4xx error handling logic below - if ( - record.code == 401 && !checkBoxCaptchaToken - ) { + if (record.code == 401 && !checkBoxCaptchaToken) { toast.warning( "Please complete an additional verification step to confirm your submission", ); @@ -494,13 +502,15 @@ export default function JoinForm() { })} {/* This first captcha isn't actually displayed, it just silently collects user metrics and generates a token */} - { recaptchaV3Key ? + {recaptchaV3Key ? ( : <> - } + /> + ) : ( + <> + )} {/* This second captcha is the traditional "I'm not a robot" checkbox, only shown if the user gets 401'ed due to a low score on the above captcha */} {isProbablyABot && recaptchaV2Key ? ( From b9d2a2e9d53e22d453d56ca56abae019a607fb90 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Tue, 19 Nov 2024 18:15:28 -0500 Subject: [PATCH 06/16] Fix: submission hangs when env var isn't set --- src/components/JoinForm/JoinForm.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index f977ca2..8b323ce 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -98,6 +98,7 @@ export default function JoinForm() { const [recaptchaV3Key, setRecaptchaV3Key] = useState( undefined, ); + const [reCaptchaError, setReCaptchaError] = useState(false); const isBeta = true; @@ -202,12 +203,13 @@ export default function JoinForm() { // Get the v3 captcha token. Per the google docs, the implicit token must be retrieved on form submission, // so that the token doesn't expire before server side validation let recaptchaInvisibleToken = ""; - if (recaptchaV3Ref?.current) { + if (recaptchaV3Ref?.current && !reCaptchaError) { recaptchaInvisibleToken = (await recaptchaV3Ref.current.executeAsync()) ?? ""; + recaptchaV3Ref.current.reset(); } else { console.warn( - "No ref found for the recaptchaV3 component, not including captcha token in HTTP request", + "No ref found for the recaptchaV3 component, or component is in error state, not including captcha token in HTTP request", ); } @@ -507,6 +509,14 @@ export default function JoinForm() { ref={recaptchaV3Ref} sitekey={recaptchaV3Key} size="invisible" + onErrored={() => { + console.error( + "Encountered an error while initializing or querying captcha. " + + "Disabling some frontend captcha features to avoid hangs. " + + "Are the recaptcha keys set correctly in the env variables?", + ); + setReCaptchaError(true); + }} /> ) : ( <> @@ -520,6 +530,14 @@ export default function JoinForm() { ref={recaptchaV2Ref} sitekey={recaptchaV2Key} onChange={(newToken) => setCheckBoxCaptchaToken(newToken ?? "")} + onErrored={() => { + console.error( + "Encountered an error while initializing or querying captcha. " + + "Disabling all frontend captcha features to avoid hangs. " + + "Are the recaptcha keys set correctly in the env variables?", + ); + setReCaptchaError(true); + }} /> ) : ( <> From 839155c83efaf06b5655faf40f550a21b77a4320 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Tue, 19 Nov 2024 21:01:50 -0500 Subject: [PATCH 07/16] Willard is cray --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 851786a..ea41afb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -34,7 +34,7 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright tests - run: npx playwright test --grep-invert 'fail meshdb hard down and s3 hard down' + run: npx playwright test env: NEXT_PUBLIC_MESHDB_URL: https://127.0.0.1:8000 # Throwaway to make the mock work MESHDB_URL: https://127.0.0.1:8000 # Throwaway to make the mock work From 997aab28e8a1da979935fd1487d058718ea3537d Mon Sep 17 00:00:00 2001 From: Willard Nilges Date: Thu, 21 Nov 2024 18:43:42 -0500 Subject: [PATCH 08/16] Check toast message returned when additional validation is required --- tests/02_join_form.spec.ts | 17 +++++++++++++++++ tests/mock/handlers.ts | 7 ++++++- tests/util.ts | 10 ++++++---- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/02_join_form.spec.ts b/tests/02_join_form.spec.ts index 8a49b33..a5e7e06 100644 --- a/tests/02_join_form.spec.ts +++ b/tests/02_join_form.spec.ts @@ -14,6 +14,7 @@ import { expectSuccess, sampleJoinRecord, findJoinRecord, + triggerCapchaV2Response, } from "./util"; import { isDeepStrictEqual } from "util"; @@ -185,6 +186,22 @@ test("street address trust me bro", async ({ page }) => { await expectSuccess(page, unitTestTimeout); }); +test("user triggered captchaV2", async ({page}) => { + test.setTimeout(joinFormTimeout); + await page.goto("/join"); + + // Is the page title correct? + await expect(page).toHaveTitle(/Join Our Community Network!/); + + // Set up sample data. + let botTriggeringData: JoinFormValues = Object.assign({}, sampleData); + botTriggeringData.referral = triggerCapchaV2Response; + + await fillOutJoinForm(page, botTriggeringData); + + await submitAndCheckToast(page, "Please complete an additional verification step to confirm your submission"); +}); + // TODO: Add a garbage testcase // TODO: Add confirm block (is this trust me bro?) diff --git a/tests/mock/handlers.ts b/tests/mock/handlers.ts index d5e78a4..e557025 100644 --- a/tests/mock/handlers.ts +++ b/tests/mock/handlers.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from "msw"; -import { expectedAPIRequestData } from "../util"; +import { expectedAPIRequestData, triggerCapchaV2Response } from "../util"; import { isDeepStrictEqual } from "util"; import { JoinFormResponse, @@ -73,6 +73,11 @@ export default [ return HttpResponse.json(r, { status: 409 }); } + // Mock response for if we want to trigger a capchaV2 response + if (joinRequest.referral === triggerCapchaV2Response) { + return HttpResponse.json({"detail": "Captcha verification failed"}, { status: 401 }); + } + // If anything else is wrong with the form we got, then bail if (!isDeepStrictEqual(joinRequest, expectedAPIRequestData)) { console.error( diff --git a/tests/util.ts b/tests/util.ts index 1acdd62..b1515f8 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -3,6 +3,8 @@ import { JoinRecord } from "@/lib/types"; import { JoinFormValues } from "@/components/JoinForm/JoinForm"; import { expect, Page } from "@playwright/test"; +export const triggerCapchaV2Response = "Mock I Am A Robot Beep Boop."; + export const sampleData: JoinFormValues = { first_name: "Jon", last_name: "Smith", @@ -14,7 +16,7 @@ export const sampleData: JoinFormValues = { state: "NY", zip_code: "11238", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, }; @@ -30,7 +32,7 @@ export const sampleJoinRecord: JoinRecord = { state: "NY", zip_code: "11238", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, submission_time: "2024-11-01T08:24:24", @@ -50,7 +52,7 @@ export const expectedTrustMeBroValues: JoinFormValues = { state: "NY", zip_code: "11238", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, }; @@ -66,7 +68,7 @@ export const sampleNJData: JoinFormValues = { zip_code: "07030", apartment: "1", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, }; From 7121a5067470e99f667b974d81e5982be1803da3 Mon Sep 17 00:00:00 2001 From: Willard Nilges Date: Thu, 21 Nov 2024 20:56:23 -0500 Subject: [PATCH 09/16] Got it --- tests/02_join_form.spec.ts | 9 ++++++++- tests/mock/handlers.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/02_join_form.spec.ts b/tests/02_join_form.spec.ts index a5e7e06..b2b36f6 100644 --- a/tests/02_join_form.spec.ts +++ b/tests/02_join_form.spec.ts @@ -195,11 +195,18 @@ test("user triggered captchaV2", async ({page}) => { // Set up sample data. let botTriggeringData: JoinFormValues = Object.assign({}, sampleData); - botTriggeringData.referral = triggerCapchaV2Response; + //botTriggeringData.referral = triggerCapchaV2Response; await fillOutJoinForm(page, botTriggeringData); await submitAndCheckToast(page, "Please complete an additional verification step to confirm your submission"); + + await page.waitForTimeout(1000); + + // Make the robot check the "I'm not a robot" button (commit voter fraud) + await page.locator("[title='reCAPTCHA']").nth(1).contentFrame().locator("[id='recaptcha-anchor']").click(); + + await submitSuccessExpected(page, unitTestTimeout); }); // TODO: Add a garbage testcase diff --git a/tests/mock/handlers.ts b/tests/mock/handlers.ts index e557025..1a32e36 100644 --- a/tests/mock/handlers.ts +++ b/tests/mock/handlers.ts @@ -74,7 +74,8 @@ export default [ } // Mock response for if we want to trigger a capchaV2 response - if (joinRequest.referral === triggerCapchaV2Response) { + if (request.headers.get("X-Recaptcha-V2-Token") === "") { + //if (joinRequest.referral === triggerCapchaV2Response) { return HttpResponse.json({"detail": "Captcha verification failed"}, { status: 401 }); } From 57ed74d02a2e93347e80db7ceaeef2398bba9686 Mon Sep 17 00:00:00 2001 From: Willard Nilges Date: Thu, 21 Nov 2024 21:06:20 -0500 Subject: [PATCH 10/16] Finish setting up reCAPTCHA test --- .github/workflows/playwright-captcha.yml | 57 ++++++++++++++++++++++++ tests/02_join_form.spec.ts | 51 +++++++++++---------- tests/mock/handlers.ts | 3 +- tests/util.ts | 2 - 4 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/playwright-captcha.yml diff --git a/.github/workflows/playwright-captcha.yml b/.github/workflows/playwright-captcha.yml new file mode 100644 index 0000000..44e6f25 --- /dev/null +++ b/.github/workflows/playwright-captcha.yml @@ -0,0 +1,57 @@ +name: Playwright reCAPTCHA Tests +permissions: read-all +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + test: + name: Run Playwright reCAPTCHA Tests + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 + with: + node-version: lts/* + - name: Setup Minio + run: | + docker run -d -p 9000:9000 --name minio \ + -e "MINIO_ACCESS_KEY=sampleaccesskey" \ + -e "MINIO_SECRET_KEY=samplesecretkey" \ + -e "MINIO_DEFAULT_BUCKETS=meshdb-join-form-log" \ + -v /tmp/data:/data \ + -v /tmp/config:/root/.minio \ + minio/minio server /data + + export AWS_ACCESS_KEY_ID=sampleaccesskey + export AWS_SECRET_ACCESS_KEY=samplesecretkey + export AWS_EC2_METADATA_DISABLED=true + aws --endpoint-url http://127.0.0.1:9000/ s3 mb s3://meshdb-join-form-log + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: RUN_CAPTCHA=true npx playwright test -g 'user triggered captchaV2' + env: + NEXT_PUBLIC_MESHDB_URL: https://127.0.0.1:8000 # Throwaway to make the mock work + MESHDB_URL: https://127.0.0.1:8000 # Throwaway to make the mock work + # We now check the JoinRecord stuff, so submit that too. + JOIN_RECORD_BUCKET_NAME: meshdb-join-form-log + JOIN_RECORD_PREFIX: dev-join-form-submissions + S3_ENDPOINT: http://127.0.0.1:9000 + AWS_ACCESS_KEY_ID: sampleaccesskey + AWS_SECRET_ACCESS_KEY: samplesecretkey + AWS_REGION: us-east-1 + # This is an invisible key that says the user is a robot + RECAPTCHA_V3_KEY: ${{ secrets.TESTING_RECAPTCHA_V3_KEY }} + RECAPTCHA_V2_KEY: ${{ secrets.TESTING_RECAPTCHA_V2_KEY }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/tests/02_join_form.spec.ts b/tests/02_join_form.spec.ts index b2b36f6..d2c4899 100644 --- a/tests/02_join_form.spec.ts +++ b/tests/02_join_form.spec.ts @@ -14,7 +14,6 @@ import { expectSuccess, sampleJoinRecord, findJoinRecord, - triggerCapchaV2Response, } from "./util"; import { isDeepStrictEqual } from "util"; @@ -186,29 +185,6 @@ test("street address trust me bro", async ({ page }) => { await expectSuccess(page, unitTestTimeout); }); -test("user triggered captchaV2", async ({page}) => { - test.setTimeout(joinFormTimeout); - await page.goto("/join"); - - // Is the page title correct? - await expect(page).toHaveTitle(/Join Our Community Network!/); - - // Set up sample data. - let botTriggeringData: JoinFormValues = Object.assign({}, sampleData); - //botTriggeringData.referral = triggerCapchaV2Response; - - await fillOutJoinForm(page, botTriggeringData); - - await submitAndCheckToast(page, "Please complete an additional verification step to confirm your submission"); - - await page.waitForTimeout(1000); - - // Make the robot check the "I'm not a robot" button (commit voter fraud) - await page.locator("[title='reCAPTCHA']").nth(1).contentFrame().locator("[id='recaptcha-anchor']").click(); - - await submitSuccessExpected(page, unitTestTimeout); -}); - // TODO: Add a garbage testcase // TODO: Add confirm block (is this trust me bro?) @@ -439,3 +415,30 @@ test("fail nj", async ({ page }) => { ); } }); + + +test.describe("user triggered captchaV2", () => { + test.skip(process.env.RUN_CAPTCHA !== "true"); + test("user triggered captchaV2", async ({page}) => { + test.setTimeout(joinFormTimeout); + await page.goto("/join"); + + // Is the page title correct? + await expect(page).toHaveTitle(/Join Our Community Network!/); + + // Set up sample data. + let botTriggeringData: JoinFormValues = Object.assign({}, sampleData); + + await fillOutJoinForm(page, botTriggeringData); + + await submitAndCheckToast(page, "Please complete an additional verification step to confirm your submission"); + + await page.waitForTimeout(1000); + + // Make the robot check the "I'm not a robot" button (commit voter fraud) + await page.locator("[title='reCAPTCHA']").nth(1).contentFrame().locator("[id='recaptcha-anchor']").click(); + + await submitSuccessExpected(page, unitTestTimeout); + }); + +}); diff --git a/tests/mock/handlers.ts b/tests/mock/handlers.ts index 1a32e36..1b26c3f 100644 --- a/tests/mock/handlers.ts +++ b/tests/mock/handlers.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from "msw"; -import { expectedAPIRequestData, triggerCapchaV2Response } from "../util"; +import { expectedAPIRequestData } from "../util"; import { isDeepStrictEqual } from "util"; import { JoinFormResponse, @@ -75,7 +75,6 @@ export default [ // Mock response for if we want to trigger a capchaV2 response if (request.headers.get("X-Recaptcha-V2-Token") === "") { - //if (joinRequest.referral === triggerCapchaV2Response) { return HttpResponse.json({"detail": "Captcha verification failed"}, { status: 401 }); } diff --git a/tests/util.ts b/tests/util.ts index b1515f8..a5674b2 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -3,8 +3,6 @@ import { JoinRecord } from "@/lib/types"; import { JoinFormValues } from "@/components/JoinForm/JoinForm"; import { expect, Page } from "@playwright/test"; -export const triggerCapchaV2Response = "Mock I Am A Robot Beep Boop."; - export const sampleData: JoinFormValues = { first_name: "Jon", last_name: "Smith", From 941268bc0366ae41aeed3c111a83949004b2357c Mon Sep 17 00:00:00 2001 From: Willard Nilges Date: Thu, 21 Nov 2024 21:25:45 -0500 Subject: [PATCH 11/16] Check if RECAPTCHA_V2_KEY is set --- tests/02_join_form.spec.ts | 18 ++++++++++++------ tests/mock/handlers.ts | 9 ++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/02_join_form.spec.ts b/tests/02_join_form.spec.ts index d2c4899..eaee3f0 100644 --- a/tests/02_join_form.spec.ts +++ b/tests/02_join_form.spec.ts @@ -416,10 +416,9 @@ test("fail nj", async ({ page }) => { } }); - test.describe("user triggered captchaV2", () => { test.skip(process.env.RUN_CAPTCHA !== "true"); - test("user triggered captchaV2", async ({page}) => { + test("user triggered captchaV2", async ({ page }) => { test.setTimeout(joinFormTimeout); await page.goto("/join"); @@ -431,14 +430,21 @@ test.describe("user triggered captchaV2", () => { await fillOutJoinForm(page, botTriggeringData); - await submitAndCheckToast(page, "Please complete an additional verification step to confirm your submission"); + await submitAndCheckToast( + page, + "Please complete an additional verification step to confirm your submission", + ); await page.waitForTimeout(1000); - + // Make the robot check the "I'm not a robot" button (commit voter fraud) - await page.locator("[title='reCAPTCHA']").nth(1).contentFrame().locator("[id='recaptcha-anchor']").click(); + await page + .locator("[title='reCAPTCHA']") + .nth(1) + .contentFrame() + .locator("[id='recaptcha-anchor']") + .click(); await submitSuccessExpected(page, unitTestTimeout); }); - }); diff --git a/tests/mock/handlers.ts b/tests/mock/handlers.ts index 1b26c3f..0a3afff 100644 --- a/tests/mock/handlers.ts +++ b/tests/mock/handlers.ts @@ -73,9 +73,12 @@ export default [ return HttpResponse.json(r, { status: 409 }); } - // Mock response for if we want to trigger a capchaV2 response - if (request.headers.get("X-Recaptcha-V2-Token") === "") { - return HttpResponse.json({"detail": "Captcha verification failed"}, { status: 401 }); + // Mock response for if we want to trigger a captchaV2 response + if (request.headers.get("X-Recaptcha-V2-Token") === "" && process.env.RECAPTCHA_V2_KEY) { + return HttpResponse.json( + { detail: "Captcha verification failed" }, + { status: 401 }, + ); } // If anything else is wrong with the form we got, then bail From f99041f7720a9d06bed6d4a1fa0551abf491ce91 Mon Sep 17 00:00:00 2001 From: Willard Nilges Date: Thu, 21 Nov 2024 21:37:45 -0500 Subject: [PATCH 12/16] pretty --- tests/mock/handlers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/mock/handlers.ts b/tests/mock/handlers.ts index 0a3afff..8d4cadf 100644 --- a/tests/mock/handlers.ts +++ b/tests/mock/handlers.ts @@ -74,7 +74,10 @@ export default [ } // Mock response for if we want to trigger a captchaV2 response - if (request.headers.get("X-Recaptcha-V2-Token") === "" && process.env.RECAPTCHA_V2_KEY) { + if ( + request.headers.get("X-Recaptcha-V2-Token") === "" && + process.env.RECAPTCHA_V2_KEY + ) { return HttpResponse.json( { detail: "Captcha verification failed" }, { status: 401 }, From a284f6dfa806f77821790a32d72329a8356378c6 Mon Sep 17 00:00:00 2001 From: Willard Nilges Date: Thu, 21 Nov 2024 22:03:22 -0500 Subject: [PATCH 13/16] Add secrets, log a warning in the backend --- .github/workflows/deploy-to-k8s.yaml | 4 +++- infra/helm/meshforms/templates/secrets.yaml | 2 ++ src/lib/endpoint.ts | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-to-k8s.yaml b/.github/workflows/deploy-to-k8s.yaml index b3307c6..fce83e2 100644 --- a/.github/workflows/deploy-to-k8s.yaml +++ b/.github/workflows/deploy-to-k8s.yaml @@ -49,7 +49,9 @@ jobs: --set meshforms.s3_bucket_name="${{ secrets.S3_BUCKET_NAME }}" \ --set meshforms.s3_base_name="${{ secrets.S3_BASE_NAME }}" \ --set image.tag="${{ inputs.environment }}" \ - --set ingress.hosts[0].host="${{ vars.INGRESS_HOST }}",ingress.hosts[0].paths[0].path=/,ingress.hosts[0].paths[0].pathType=Prefix + --set ingress.hosts[0].host="${{ vars.INGRESS_HOST }}",ingress.hosts[0].paths[0].path=/,ingress.hosts[0].paths[0].pathType=Prefix \ + --set meshforms.recaptcha_v2_key="${{ secrets.RECAPTCHA_V2_KEY }}" \ + --set meshforms.recaptcha_v3_key="${{ secrets.RECAPTCHA_V3_KEY }}" # Rolling restart kubectl --kubeconfig ./config --server https://${{ secrets.SSH_TARGET_IP }}:6443 -n ${{ vars.APP_NAMESPACE }} rollout restart deploy diff --git a/infra/helm/meshforms/templates/secrets.yaml b/infra/helm/meshforms/templates/secrets.yaml index f561d24..c2b108f 100644 --- a/infra/helm/meshforms/templates/secrets.yaml +++ b/infra/helm/meshforms/templates/secrets.yaml @@ -10,3 +10,5 @@ data: S3_BASE_NAME: {{ .Values.meshforms.s3_base_name | b64enc | quote }} NEXT_PUBLIC_MESHDB_URL: {{ .Values.meshforms.meshdb_url | b64enc | quote }} MESHDB_URL: {{ .Values.meshforms.meshdb_url | b64enc | quote }} + RECAPTCHA_V2_KEY: {{ .Values.meshforms.recaptcha_v2_key | b64enc | quote }} + RECAPTCHA_V3_KEY: {{ .Values.meshforms.recaptcha_v3_key | b64enc | quote }} diff --git a/src/lib/endpoint.ts b/src/lib/endpoint.ts index 75e62ce..0a4ffcd 100644 --- a/src/lib/endpoint.ts +++ b/src/lib/endpoint.ts @@ -10,5 +10,13 @@ export async function getMeshDBAPIEndpoint() { // Literally just ask the server what captcha keys to use export async function getRecaptchaKeys() { + if (!process.env.RECAPTCHA_V2_KEY) { + console.warn("RECAPTCHA_V2_KEY not set"); + } + + if (!process.env.RECAPTCHA_V3_KEY) { + console.warn("RECAPTCHA_V3_KEY not set"); + } + return [process.env.RECAPTCHA_V2_KEY, process.env.RECAPTCHA_V3_KEY]; } From 03ce87575aa199a48b772bf361e09045f76d5520 Mon Sep 17 00:00:00 2001 From: Willard Nilges Date: Thu, 21 Nov 2024 22:17:09 -0500 Subject: [PATCH 14/16] Yea probably - James 2024 --- infra/helm/meshforms/templates/secrets.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/helm/meshforms/templates/secrets.yaml b/infra/helm/meshforms/templates/secrets.yaml index c2b108f..646b9e8 100644 --- a/infra/helm/meshforms/templates/secrets.yaml +++ b/infra/helm/meshforms/templates/secrets.yaml @@ -10,5 +10,5 @@ data: S3_BASE_NAME: {{ .Values.meshforms.s3_base_name | b64enc | quote }} NEXT_PUBLIC_MESHDB_URL: {{ .Values.meshforms.meshdb_url | b64enc | quote }} MESHDB_URL: {{ .Values.meshforms.meshdb_url | b64enc | quote }} - RECAPTCHA_V2_KEY: {{ .Values.meshforms.recaptcha_v2_key | b64enc | quote }} - RECAPTCHA_V3_KEY: {{ .Values.meshforms.recaptcha_v3_key | b64enc | quote }} + RECAPTCHA_V2_KEY: {{ .Values.meshforms.recaptcha_v2_key | default "" | b64enc | quote }} + RECAPTCHA_V3_KEY: {{ .Values.meshforms.recaptcha_v3_key | default "" | b64enc | quote }} From f4fbdbb7344b45b4808ff8cc8ca7569ecc2ba658 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Thu, 21 Nov 2024 22:16:59 -0500 Subject: [PATCH 15/16] Captcha translations --- messages/en.json | 12 +++++++++++- package-lock.json | 9 +++++++++ package.json | 1 + src/app/[locale]/layout.tsx | 7 +++++-- src/components/JoinForm/JoinForm.tsx | 18 ++++++++++-------- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/messages/en.json b/messages/en.json index 99ec441..78406e3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,4 +1,7 @@ { + "Manifest": { + "name": "NYC Mesh MeshForms" + }, "LocaleSwitcher": { "label": "Language", "locale": "{locale, select, en {🇺🇸 English} es {🇪🇸 Español} fr {🇫🇷 Français} ht {🇭🇹 Haitian Creole} zh {🇨🇳 中文} other {Unknown}}" @@ -31,7 +34,8 @@ "submit": { "submit": "Submit", "loading": "Loading...", - "thanks": "Thanks!" + "thanks": "Thanks!", + "goHome": "Go Home" } }, "states": { @@ -44,6 +48,12 @@ "minutes": "5-10 minutes", "days": "2-3 days", "support": "If you do not see the email, please check your \"Spam\" folder, or email support@nycmesh.net for help." + }, + "errors": { + "error": "Could not submit Join Form:", + "errorTryAgain": "Could not submit Join Form. Please try again later, or contact support@nycmesh.net for assistance.", + "confirm": "Please confirm some information", + "captchaFail": "Please complete an additional verification step to confirm your submission" } } } diff --git a/package-lock.json b/package-lock.json index 7fe2a67..179ded2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "ag-grid-react": "^31.0.3", "aws-sdk": "^2.1659.0", "bootstrap": "^5.3.2", + "deepmerge": "^4.3.1", "libphonenumber-js": "^1.11.11", "msw": "^2.3.5", "next": "latest", @@ -3108,6 +3109,14 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", diff --git a/package.json b/package.json index fc69da4..ccbae28 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "ag-grid-react": "^31.0.3", "aws-sdk": "^2.1659.0", "bootstrap": "^5.3.2", + "deepmerge": "^4.3.1", "libphonenumber-js": "^1.11.11", "msw": "^2.3.5", "next": "latest", diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 471ece6..87054ea 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { routing } from "@/i18n/routing"; import { getMessages } from "next-intl/server"; import { NextIntlClientProvider } from "next-intl"; +import deepmerge from "deepmerge"; export default async function RootLayout({ children, @@ -12,12 +13,14 @@ export default async function RootLayout({ params: { locale: string }; }) { // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!locale || !routing.locales.includes(locale as any)) { notFound(); } // Provide all messages to the client - const messages = await getMessages(); + const localeMessages = await getMessages({locale}); + const defaultMessages = await getMessages({locale: 'en'}); + const messages = deepmerge(defaultMessages, localeMessages); return ( diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index 8b323ce..b40073b 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -18,7 +18,7 @@ import { saveJoinRecordToS3 } from "@/lib/join_record"; import { getMeshDBAPIEndpoint, getRecaptchaKeys } from "@/lib/endpoint"; import InfoConfirmationDialog from "../InfoConfirmation/InfoConfirmation"; import { JoinRecord } from "@/lib/types"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import LocaleSwitcher from "../LocaleSwitcher"; import ReCAPTCHA from "react-google-recaptcha"; @@ -82,6 +82,8 @@ export default function JoinForm() { const recaptchaV3Ref = React.useRef(null); const recaptchaV2Ref = React.useRef(null); + const locale = useLocale(); + const [isLoading, setIsLoading] = useState(false); const [isProbablyABot, setIsProbablyABot] = useState(false); const [checkBoxCaptchaToken, setCheckBoxCaptchaToken] = useState(""); @@ -291,7 +293,7 @@ export default function JoinForm() { setInfoToConfirm(needsConfirmation); setIsInfoConfirmationDialogueOpen(true); - toast.warning("Please confirm some information"); + toast.warning(t("errors.confirm")); return; } @@ -300,7 +302,7 @@ export default function JoinForm() { // and are still seeing a 401, something has gone wrong - fall back to the generic 4xx error handling logic below if (record.code == 401 && !checkBoxCaptchaToken) { toast.warning( - "Please complete an additional verification step to confirm your submission", + t("errors.captchaFail") ); setIsProbablyABot(true); setIsSubmitted(false); @@ -328,7 +330,7 @@ export default function JoinForm() { const detail = error.detail; // This looks disgusting when Debug is on in MeshDB because it replies with HTML. // There's probably a way to coax the exception out of the response somewhere - toast.error(`Could not submit Join Form: ${detail}`); + toast.error(t("errors.error") + " " + detail.toString()); console.error(`An error occurred: ${detail}`); setIsLoading(false); @@ -353,9 +355,7 @@ export default function JoinForm() { } else { // If MeshDB is down AND we failed to save the Join Record, then we should // probably let the member know to try again later. - toast.error( - `Could not submit Join Form. Please try again later, or contact support@nycmesh.net for assistance.`, - ); + toast.error(t("errors.errorTryAgain")); setIsLoading(false); } @@ -509,6 +509,7 @@ export default function JoinForm() { ref={recaptchaV3Ref} sitekey={recaptchaV3Key} size="invisible" + hl={locale} onErrored={() => { console.error( "Encountered an error while initializing or querying captcha. " + @@ -529,6 +530,7 @@ export default function JoinForm() { style={{ marginTop: "15px" }} ref={recaptchaV2Ref} sitekey={recaptchaV2Key} + hl={locale} onChange={(newToken) => setCheckBoxCaptchaToken(newToken ?? "")} onErrored={() => { console.error( @@ -612,7 +614,7 @@ export default function JoinForm() {
From 070a320ad2829bd6170e2aa5beadbf32855e7868 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Thu, 21 Nov 2024 22:20:09 -0500 Subject: [PATCH 16/16] Formatting --- src/app/[locale]/layout.tsx | 4 ++-- src/components/JoinForm/JoinForm.tsx | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 87054ea..64124f5 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -18,8 +18,8 @@ export default async function RootLayout({ } // Provide all messages to the client - const localeMessages = await getMessages({locale}); - const defaultMessages = await getMessages({locale: 'en'}); + const localeMessages = await getMessages({ locale }); + const defaultMessages = await getMessages({ locale: "en" }); const messages = deepmerge(defaultMessages, localeMessages); return ( diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index bc17f80..c551c16 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -301,9 +301,7 @@ export default function JoinForm() { // interactive "checkbox" V2 captcha. However, if they have already submitted a checkbox captcha // and are still seeing a 401, something has gone wrong - fall back to the generic 4xx error handling logic below if (record.code == 401 && !checkBoxCaptchaToken) { - toast.warning( - t("errors.captchaFail") - ); + toast.warning(t("errors.captchaFail")); setIsProbablyABot(true); setIsSubmitted(false); setIsLoading(false);