The Hesychia key is v3. The v2 invisible widget rejected that site key on load. Closes #134.
This commit is contained in:
1 parent
83c52a06dd
commit
81b436873b
6 files changed
+154
-78
No files matched your search
Generated
-37
@@ -64,11 +64,9 @@
|
||||
"@types/lodash": "~4.17.13",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
},
|
||||
@@ -6540,15 +6538,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
@@ -19090,19 +19079,6 @@
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"prop-types": "^15.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-bootstrap": {
|
||||
"version": "2.10.9",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz",
|
||||
@@ -19301,19 +19277,6 @@
|
||||
"react": ">=16.3.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.0",
|
||||
"react-async-script": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
|
||||
|
||||
@@ -92,11 +92,9 @@
|
||||
"@types/lodash": "~4.17.13",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -3,29 +3,13 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import PasswordReset from './PasswordReset';
|
||||
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY } from './recaptchaV3';
|
||||
|
||||
const mockPost = jest.fn();
|
||||
const mockExecuteAsync = jest.fn();
|
||||
const mockReset = jest.fn();
|
||||
const mockExecute = jest.fn();
|
||||
|
||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||
|
||||
jest.mock('react-google-recaptcha', () => {
|
||||
const ReactLib = require('react');
|
||||
return ReactLib.forwardRef(
|
||||
(
|
||||
_props: unknown,
|
||||
ref: React.Ref<{ executeAsync: () => Promise<string>; reset: () => void }>
|
||||
) => {
|
||||
ReactLib.useImperativeHandle(ref, () => ({
|
||||
executeAsync: mockExecuteAsync,
|
||||
reset: mockReset,
|
||||
}));
|
||||
return <div data-testid="recaptcha" />;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('../../../axiosApi', () => ({
|
||||
cleanAxiosInstance: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
@@ -48,13 +32,23 @@ const renderPasswordReset = () =>
|
||||
describe('PasswordReset', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
mockExecuteAsync.mockReset();
|
||||
mockReset.mockReset();
|
||||
mockExecuteAsync.mockResolvedValue('captcha-token');
|
||||
mockExecute.mockReset();
|
||||
mockExecute.mockResolvedValue('captcha-token');
|
||||
mockPost.mockResolvedValue({ status: 200 });
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: mockExecute,
|
||||
};
|
||||
});
|
||||
|
||||
it('submits email with captcha token and navigates to confirmation', async () => {
|
||||
afterEach(() => {
|
||||
delete window.grecaptcha;
|
||||
document
|
||||
.querySelectorAll('script[data-recaptcha-v3="true"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it('submits email with a v3 captcha token and navigates to confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPasswordReset();
|
||||
|
||||
@@ -62,6 +56,9 @@ describe('PasswordReset', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
|
||||
action: RECAPTCHA_ACTION,
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith('user/reset_password/', {
|
||||
email: 'user@example.com',
|
||||
recaptchaToken: 'captcha-token',
|
||||
@@ -70,6 +67,20 @@ describe('PasswordReset', () => {
|
||||
expect(await screen.findByText('Confirmation page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when captcha fails and does not post', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('invalid site key'));
|
||||
const user = userEvent.setup();
|
||||
renderPasswordReset();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Unable to reset password. Please try again.')
|
||||
).toBeInTheDocument();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when request fails', async () => {
|
||||
mockPost.mockRejectedValue(new Error('network'));
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Form, Formik, Field } from 'formik';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { cleanAxiosInstance } from '../../../axiosApi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import styled from 'styled-components';
|
||||
import * as Yup from 'yup';
|
||||
import { executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -139,20 +139,20 @@ const validationSchema = Yup.object().shape({
|
||||
|
||||
const PasswordReset = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const recaptchaRef = useRef<ReCAPTCHA>(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadRecaptcha().catch(() => {
|
||||
// Submit reports a failure if the v3 script never becomes ready.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePasswordResetEmail = async ({
|
||||
email,
|
||||
}: EmailPasswordResetValues): Promise<void> => {
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let token = '';
|
||||
if (recaptchaRef.current) {
|
||||
token = (await recaptchaRef.current.executeAsync()) || '';
|
||||
recaptchaRef.current.reset();
|
||||
}
|
||||
|
||||
const token = await executePasswordResetCaptcha();
|
||||
await cleanAxiosInstance.post('user/reset_password/', {
|
||||
email,
|
||||
recaptchaToken: token,
|
||||
@@ -186,14 +186,6 @@ const PasswordReset = (): JSX.Element => {
|
||||
placeholder="Email Address"
|
||||
type="email"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey="6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ"
|
||||
size="invisible"
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
||||
Reset Password
|
||||
</StyledButton>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY, executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
|
||||
|
||||
describe('recaptchaV3', () => {
|
||||
afterEach(() => {
|
||||
delete window.grecaptcha;
|
||||
document
|
||||
.querySelectorAll('script[data-recaptcha-v3="true"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it('loads the v3 script once when grecaptcha is missing', async () => {
|
||||
const pending = loadRecaptcha();
|
||||
const again = loadRecaptcha();
|
||||
const scripts = document.querySelectorAll('script[data-recaptcha-v3="true"]');
|
||||
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0]).toHaveAttribute(
|
||||
'src',
|
||||
`https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}`
|
||||
);
|
||||
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
scripts[0].dispatchEvent(new Event('load'));
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
await expect(again).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('executes the password_reset action', async () => {
|
||||
const execute = jest.fn().mockResolvedValue('token-1');
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute,
|
||||
};
|
||||
|
||||
await expect(executePasswordResetCaptcha()).resolves.toBe('token-1');
|
||||
expect(execute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
|
||||
action: RECAPTCHA_ACTION,
|
||||
});
|
||||
expect(document.querySelector('script[data-recaptcha-v3="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty token', async () => {
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: jest.fn().mockResolvedValue(''),
|
||||
};
|
||||
|
||||
await expect(executePasswordResetCaptcha()).rejects.toThrow('empty reCAPTCHA token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
export const RECAPTCHA_SITE_KEY = '6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ';
|
||||
export const RECAPTCHA_ACTION = 'password_reset';
|
||||
|
||||
type Grecaptcha = {
|
||||
ready: (callback: () => void) => void;
|
||||
execute: (siteKey: string, options: { action: string }) => Promise<string>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha?: Grecaptcha;
|
||||
}
|
||||
}
|
||||
|
||||
let loading: Promise<void> | null = null;
|
||||
|
||||
export function loadRecaptcha(siteKey: string = RECAPTCHA_SITE_KEY): Promise<void> {
|
||||
if (window.grecaptcha) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (loading && document.querySelector('script[data-recaptcha-v3="true"]')) {
|
||||
return loading;
|
||||
}
|
||||
|
||||
loading = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
|
||||
script.async = true;
|
||||
script.dataset.recaptchaV3 = 'true';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
loading = null;
|
||||
script.remove();
|
||||
reject(new Error('reCAPTCHA failed to load'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
export async function executePasswordResetCaptcha(
|
||||
siteKey: string = RECAPTCHA_SITE_KEY
|
||||
): Promise<string> {
|
||||
await loadRecaptcha(siteKey);
|
||||
const grecaptcha = window.grecaptcha;
|
||||
if (!grecaptcha) {
|
||||
throw new Error('reCAPTCHA unavailable');
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
grecaptcha.ready(resolve);
|
||||
});
|
||||
|
||||
const token = await grecaptcha.execute(siteKey, { action: RECAPTCHA_ACTION });
|
||||
if (!token) {
|
||||
throw new Error('empty reCAPTCHA token');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
Reference in new issue
Block a user