Skip to content

Commit c8c8234

Browse files
Add reCAPTCHA integration to NewsletterSignupForm for enhanced security
1 parent 0eabaa5 commit c8c8234

3 files changed

Lines changed: 88 additions & 11 deletions

File tree

dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createRef } from 'react';
2+
import type ReactGoogleRecaptcha from 'react-google-recaptcha';
13
import { fn, mocked } from 'storybook/test';
24
import preview from '../../.storybook/preview';
35
import { useNewsletterSignupForm } from '../lib/useNewsletterSignupForm';
@@ -46,13 +48,17 @@ const noopHandlers: Pick<
4648
| 'handleSubmit'
4749
| 'handleSubmitButtonClick'
4850
| 'handleReset'
51+
| 'handleCaptchaComplete'
52+
| 'handleCaptchaLoadError'
4953
> = {
5054
handleEmailChange: fn(),
5155
handleEmailFocus: fn(),
5256
handleMarketingToggle: fn(),
5357
handleSubmit: fn(),
5458
handleSubmitButtonClick: fn(),
5559
handleReset: fn(),
60+
handleCaptchaComplete: fn(),
61+
handleCaptchaLoadError: fn(),
5662
};
5763

5864
const mockForm = (state: Partial<NewsletterSignupFormState>) => ({
@@ -64,6 +70,8 @@ const mockForm = (state: Partial<NewsletterSignupFormState>) => ({
6470
isWaitingForResponse: false,
6571
responseOk: undefined,
6672
errorMessage: undefined,
73+
recaptchaRef: createRef<ReactGoogleRecaptcha>(),
74+
captchaSiteKey: undefined,
6775
...noopHandlers,
6876
...state,
6977
});

dotcom-rendering/src/components/NewsletterSignupForm.island.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
TextInput,
1414
} from '@guardian/source/react-components';
1515
import { ToggleSwitch } from '@guardian/source-development-kitchen/react-components';
16+
// Note - the package also exports a component as a named export "ReCAPTCHA",
17+
// that version will compile and render but is non-functional.
18+
// Use the default export instead.
19+
import ReactGoogleRecaptcha from 'react-google-recaptcha';
1620
import { useNewsletterSignupForm } from '../lib/useNewsletterSignupForm';
1721
import { palette } from '../palette';
1822
import { useConfig } from './ConfigContext';
@@ -225,6 +229,10 @@ export const NewsletterSignupForm = ({
225229
isWaitingForResponse,
226230
responseOk,
227231
errorMessage,
232+
recaptchaRef,
233+
captchaSiteKey,
234+
handleCaptchaComplete,
235+
handleCaptchaLoadError,
228236
handleEmailChange,
229237
handleEmailFocus,
230238
handleMarketingToggle,
@@ -345,6 +353,15 @@ export const NewsletterSignupForm = ({
345353
</Button>
346354
</div>
347355
))}
356+
{!!captchaSiteKey && (
357+
<ReactGoogleRecaptcha
358+
sitekey={captchaSiteKey}
359+
ref={recaptchaRef}
360+
onChange={handleCaptchaComplete}
361+
onError={handleCaptchaLoadError}
362+
size="invisible"
363+
/>
364+
)}
348365
</>
349366
);
350367
};

dotcom-rendering/src/lib/useNewsletterSignupForm.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { isString } from '@guardian/libs';
22
import type { FormEvent, ReactEventHandler } from 'react';
3-
import { useEffect, useState } from 'react';
3+
import type React from 'react';
4+
import { useEffect, useRef, useState } from 'react';
5+
import type ReactGoogleRecaptcha from 'react-google-recaptcha';
46
import { submitComponentEvent } from '../client/ophan/ophan';
57
import type { RenderingTarget } from '../types/renderingTarget';
68
import { lazyFetchEmailWithTimeout } from './fetchEmail';
@@ -15,6 +17,7 @@ import { useBrowserId } from './useBrowserId';
1517
const buildFormData = (
1618
emailAddress: string,
1719
newsletterId: string,
20+
token: string,
1821
marketingOptIn?: boolean,
1922
browserId?: string,
2023
): FormData => {
@@ -28,7 +31,7 @@ const buildFormData = (
2831
formData.append('ref', pageRef);
2932
formData.append('refViewId', refViewId);
3033
formData.append('name', '');
31-
formData.append('g-recaptcha-response', '');
34+
formData.append('g-recaptcha-response', token);
3235

3336
if (marketingOptIn !== undefined) {
3437
formData.append('marketing', marketingOptIn ? 'true' : 'false');
@@ -79,7 +82,11 @@ type EventDescription =
7982
| 'form-submission'
8083
| 'submission-confirmed'
8184
| 'submission-failed'
82-
| 'form-submit-error';
85+
| 'open-captcha'
86+
| 'captcha-load-error'
87+
| 'form-submit-error'
88+
| 'captcha-not-passed'
89+
| 'captcha-passed';
8390

8491
const sendTracking = (
8592
newsletterId: string,
@@ -90,15 +97,21 @@ const sendTracking = (
9097

9198
switch (eventDescription) {
9299
case 'form-submission':
100+
case 'captcha-not-passed':
101+
case 'captcha-passed':
93102
action = 'ANSWER';
94103
break;
95104
case 'submission-confirmed':
96105
action = 'SUBSCRIBE';
97106
break;
107+
case 'captcha-load-error':
98108
case 'form-submit-error':
99109
case 'submission-failed':
100110
action = 'CLOSE';
101111
break;
112+
case 'open-captcha':
113+
action = 'EXPAND' as typeof action;
114+
break;
102115
case 'click-button':
103116
default:
104117
action = 'CLICK';
@@ -157,6 +170,15 @@ export type NewsletterSignupFormState = {
157170
/** Inline validation / network error copy. */
158171
errorMessage: string | undefined;
159172

173+
/** Ref to pass to the `<ReactGoogleRecaptcha>` widget. */
174+
recaptchaRef: React.RefObject<ReactGoogleRecaptcha>;
175+
/** Site key for the reCAPTCHA widget — `undefined` until resolved. */
176+
captchaSiteKey: string | undefined;
177+
/** Pass to `ReactGoogleRecaptcha`'s `onChange` prop. */
178+
handleCaptchaComplete: (token: string | null) => void;
179+
/** Pass to `ReactGoogleRecaptcha`'s `onError` prop. */
180+
handleCaptchaLoadError: () => void;
181+
160182
// Event handlers
161183
handleEmailChange: (value: string) => void;
162184
handleEmailFocus: () => void;
@@ -180,6 +202,8 @@ export const useNewsletterSignupForm = (
180202
newsletterId: string,
181203
renderingTarget: RenderingTarget,
182204
): NewsletterSignupFormState => {
205+
const recaptchaRef = useRef<ReactGoogleRecaptcha>(null);
206+
const [captchaSiteKey, setCaptchaSiteKey] = useState<string>();
183207
const [userEmail, setUserEmail] = useState<string>();
184208
const [hideEmailInput, setHideEmailInput] = useState(false);
185209
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
@@ -205,6 +229,7 @@ export const useNewsletterSignupForm = (
205229
}, [isSignedIn]);
206230

207231
useEffect(() => {
232+
setCaptchaSiteKey(window.guardian.config.page.googleRecaptchaSiteKey);
208233
void resolveEmailIfSignedIn().then((email) => {
209234
setUserEmail(email);
210235
setHideEmailInput(isString(email));
@@ -214,12 +239,16 @@ export const useNewsletterSignupForm = (
214239
});
215240
}, []);
216241

217-
const submitForm = async (emailAddress: string): Promise<void> => {
242+
const submitForm = async (
243+
emailAddress: string,
244+
token: string,
245+
): Promise<void> => {
218246
sendTracking(newsletterId, 'form-submission', renderingTarget);
219247

220248
const formData = buildFormData(
221249
emailAddress,
222250
newsletterId,
251+
token,
223252
marketingOptIn,
224253
browserId,
225254
);
@@ -243,6 +272,30 @@ export const useNewsletterSignupForm = (
243272
);
244273
};
245274

275+
const handleCaptchaComplete = (token: string | null): void => {
276+
if (!token) {
277+
sendTracking(newsletterId, 'captcha-not-passed', renderingTarget);
278+
setIsWaitingForResponse(false);
279+
return;
280+
}
281+
sendTracking(newsletterId, 'captcha-passed', renderingTarget);
282+
const emailAddress = userEmail?.trim() ?? '';
283+
submitForm(emailAddress, token).catch((error) => {
284+
// eslint-disable-next-line no-console -- unexpected error
285+
console.error(error);
286+
sendTracking(newsletterId, 'form-submit-error', renderingTarget);
287+
setErrorMessage('Sorry, there was an error signing you up.');
288+
setIsWaitingForResponse(false);
289+
recaptchaRef.current?.reset();
290+
});
291+
};
292+
293+
const handleCaptchaLoadError = (): void => {
294+
sendTracking(newsletterId, 'captcha-load-error', renderingTarget);
295+
setErrorMessage('Sorry, the reCAPTCHA failed to load.');
296+
recaptchaRef.current?.reset();
297+
};
298+
246299
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
247300
event.preventDefault();
248301
if (isWaitingForResponse) return;
@@ -255,13 +308,8 @@ export const useNewsletterSignupForm = (
255308

256309
setErrorMessage(undefined);
257310
setIsWaitingForResponse(true);
258-
submitForm(emailAddress).catch((error) => {
259-
// eslint-disable-next-line no-console -- unexpected error
260-
console.error(error);
261-
sendTracking(newsletterId, 'form-submit-error', renderingTarget);
262-
setErrorMessage('Sorry, there was an error signing you up.');
263-
setIsWaitingForResponse(false);
264-
});
311+
sendTracking(newsletterId, 'open-captcha', renderingTarget);
312+
recaptchaRef.current?.execute();
265313
};
266314

267315
return {
@@ -273,6 +321,10 @@ export const useNewsletterSignupForm = (
273321
isWaitingForResponse,
274322
responseOk,
275323
errorMessage,
324+
recaptchaRef,
325+
captchaSiteKey,
326+
handleCaptchaComplete,
327+
handleCaptchaLoadError,
276328
handleEmailChange: (value) => {
277329
setUserEmail(value);
278330
setIsInteracted(true);

0 commit comments

Comments
 (0)