diff --git a/package-lock.json b/package-lock.json
index a016ef4c..b13f0282 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-google-recaptcha": "^3.1.0",
+ "react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",
@@ -21912,6 +21913,18 @@
"react": ">=16.4.1"
}
},
+ "node_modules/react-google-recaptcha-v3": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.11.0.tgz",
+ "integrity": "sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "peerDependencies": {
+ "react": "^16.3 || ^17.0 || ^18.0 || ^19.0",
+ "react-dom": "^17.0 || ^18.0 || ^19.0"
+ }
+ },
"node_modules/react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
diff --git a/package.json b/package.json
index 0ba12e7c..90da23c8 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-google-recaptcha": "^3.1.0",
+ "react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",
diff --git a/src/discussions/discussions-home/DiscussionContent.jsx b/src/discussions/discussions-home/DiscussionContent.jsx
index e7542595..e9107a35 100644
--- a/src/discussions/discussions-home/DiscussionContent.jsx
+++ b/src/discussions/discussions-home/DiscussionContent.jsx
@@ -1,38 +1,46 @@
import React, { lazy, Suspense } from 'react';
+import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import { useSelector } from 'react-redux';
import { Route, Routes } from 'react-router-dom';
import Spinner from '../../components/Spinner';
import { Routes as ROUTES } from '../../data/constants';
+import { selectCaptchaSettings } from '../data/selectors';
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
const DiscussionContent = () => {
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
+ const captchaSettings = useSelector(selectCaptchaSettings);
return (
-
-
-
)}>
-
- {postEditorVisible ? (
- } />
- ) : (
- <>
- {ROUTES.POSTS.EDIT_POST.map(route => (
- } />
- ))}
- {ROUTES.COMMENTS.PATH.map(route => (
- } />
- ))}
- >
- )}
-
-
+
+
+
+ )}>
+
+ {postEditorVisible ? (
+ } />
+ ) : (
+ <>
+ {ROUTES.POSTS.EDIT_POST.map(route => (
+ } />
+ ))}
+ {ROUTES.COMMENTS.PATH.map(route => (
+ } />
+ ))}
+ >
+ )}
+
+
+
-
+
);
};
diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx
index 8009e592..2de52873 100644
--- a/src/discussions/post-comments/PostCommentsView.test.jsx
+++ b/src/discussions/post-comments/PostCommentsView.test.jsx
@@ -1,9 +1,8 @@
-import React, { useRef } from 'react';
-
import {
act, fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
+import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { IntlProvider } from 'react-intl';
import {
MemoryRouter, Route, Routes, useLocation,
@@ -27,12 +26,6 @@ import fetchCourseConfig from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThread, fetchThreads } from '../posts/data/thunks';
-import MockReCAPTCHA, {
- mockOnChange,
- mockOnError,
- mockOnExpired,
- mockReset,
-} from '../posts/post-editor/mocksData/react-google-recaptcha';
import fetchCourseTopics from '../topics/data/thunks';
import { getDiscussionTourUrl } from '../tours/data/api';
import selectTours from '../tours/data/selectors';
@@ -63,7 +56,11 @@ let testLocation;
let container;
let unmount;
-jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
+jest.mock('react-google-recaptcha-v3', () => ({
+ useGoogleReCaptcha: jest.fn(),
+ // eslint-disable-next-line react/prop-types
+ GoogleReCaptchaProvider: ({ children }) =>
{children}
,
+}));
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
@@ -310,6 +307,8 @@ describe('ThreadView', () => {
it('should show and hide the editor', async () => {
await setupCourseConfig();
+ const mockExecuteRecaptchaNew = jest.fn(() => Promise.resolve('mock-token'));
+ useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptchaNew });
await waitFor(() => renderComponent(discussionPostId));
const post = screen.getByTestId('post-thread-1');
@@ -348,13 +347,18 @@ describe('ThreadView', () => {
it('should allow posting a comment with CAPTCHA', async () => {
await setupCourseConfig(true, false, false);
+ const mockExecuteRecaptcha = jest.fn(() => Promise.resolve('mock-token'));
+ useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
+
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
+
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
+
+ await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
- await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
await waitFor(() => {
@@ -366,10 +370,43 @@ describe('ThreadView', () => {
raw_body: 'New comment with CAPTCHA',
thread_id: 'thread-1',
});
- expect(mockOnChange).toHaveBeenCalled();
});
});
+ it('should show captcha error if executeRecaptcha returns null token', async () => {
+ await setupCourseConfig(true, false, false);
+
+ const mockExecuteRecaptcha = jest.fn().mockResolvedValue(null);
+ useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
+
+ await waitFor(() => renderComponent(discussionPostId));
+
+ const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
+ const hoverCard = within(comment).getByTestId('hover-card-comment-1');
+ await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
+ await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
+ await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
+
+ expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
+ });
+
+ it('should show captcha error if executeRecaptcha throws', async () => {
+ await setupCourseConfig(true, false, false);
+
+ const mockExecuteRecaptcha = jest.fn().mockRejectedValue(new Error('recaptcha failed'));
+ useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
+
+ await waitFor(() => renderComponent(discussionPostId));
+
+ const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
+ const hoverCard = within(comment).getByTestId('hover-card-comment-1');
+ await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
+ await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
+ await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
+
+ expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
+ });
+
it('should allow posting a comment', async () => {
await setupCourseConfig();
await waitFor(() => renderComponent(discussionPostId));
@@ -659,46 +696,6 @@ describe('ThreadView', () => {
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
- it('renders the mocked ReCAPTCHA.', async () => {
- await setupCourseConfig(true, false, false);
- await waitFor(() => renderComponent(discussionPostId));
- await act(async () => {
- fireEvent.click(screen.queryByText('Add comment'));
- });
- expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
- });
-
- it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
- await setupCourseConfig(true, false, false);
- await waitFor(() => renderComponent(discussionPostId));
- await act(async () => {
- fireEvent.click(screen.queryByText('Add comment'));
- });
- const solveButton = screen.getByText('Solve CAPTCHA');
- fireEvent.click(solveButton);
- expect(mockOnChange).toHaveBeenCalled();
- });
-
- it('successfully calls onExpired handler when CAPTCHA expires', async () => {
- await setupCourseConfig(true, false, false);
- await waitFor(() => renderComponent(discussionPostId));
- await act(async () => {
- fireEvent.click(screen.queryByText('Add comment'));
- });
- fireEvent.click(screen.getByText('Expire CAPTCHA'));
- expect(mockOnExpired).toHaveBeenCalled();
- });
-
- it('successfully calls onError handler when CAPTCHA errors', async () => {
- await setupCourseConfig(true, false, false);
- await waitFor(() => renderComponent(discussionPostId));
- await act(async () => {
- fireEvent.click(screen.queryByText('Add comment'));
- });
- fireEvent.click(screen.getByText('Error CAPTCHA'));
- expect(mockOnError).toHaveBeenCalled();
- });
-
it('shown post not found when post id does not belong to course', async () => {
await waitFor(() => renderComponent('unloaded-id'));
expect(await screen.findByText('Thread not found', { exact: true }))
@@ -1161,61 +1158,3 @@ describe('ThreadView', () => {
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
});
});
-
-describe('MockReCAPTCHA', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- test('uses defaultProps when props are not provided', () => {
- render(
);
-
- expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
-
- fireEvent.click(screen.getByText('Solve CAPTCHA'));
- fireEvent.click(screen.getByText('Expire CAPTCHA'));
- fireEvent.click(screen.getByText('Error CAPTCHA'));
-
- expect(mockOnChange).toHaveBeenCalled();
- expect(mockOnExpired).toHaveBeenCalled();
- expect(mockOnError).toHaveBeenCalled();
- });
-
- it('triggers all callbacks and exposes reset via ref', () => {
- const onChange = jest.fn();
- const onExpired = jest.fn();
- const onError = jest.fn();
-
- const Wrapper = () => {
- const recaptchaRef = useRef(null);
- return (
-
-
-
-
- );
- };
-
- const { getByText, getByTestId } = render(
);
-
- fireEvent.click(getByText('Solve CAPTCHA'));
- fireEvent.click(getByText('Expire CAPTCHA'));
- fireEvent.click(getByText('Error CAPTCHA'));
-
- fireEvent.click(getByTestId('reset-btn'));
-
- expect(mockOnChange).toHaveBeenCalled();
- expect(mockOnExpired).toHaveBeenCalled();
- expect(mockOnError).toHaveBeenCalled();
-
- expect(onChange).toHaveBeenCalledWith('mock-token');
- expect(onExpired).toHaveBeenCalled();
- expect(onError).toHaveBeenCalled();
- expect(mockReset).toHaveBeenCalled();
- });
-});
diff --git a/src/discussions/post-comments/comments/comment/CommentEditor.jsx b/src/discussions/post-comments/comments/comment/CommentEditor.jsx
index 94d045bc..c8fd84dd 100644
--- a/src/discussions/post-comments/comments/comment/CommentEditor.jsx
+++ b/src/discussions/post-comments/comments/comment/CommentEditor.jsx
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import { Button, Form, StatefulButton } from '@openedx/paragon';
import { Formik } from 'formik';
-import ReCAPTCHA from 'react-google-recaptcha';
+import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useSelector } from 'react-redux';
import * as Yup from 'yup';
@@ -49,7 +49,7 @@ const CommentEditor = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const editorRef = useRef(null);
const formRef = useRef(null);
- const recaptchaRef = useRef(null);
+ const { executeRecaptcha } = useGoogleReCaptcha();
const { authenticatedUser } = useContext(AppContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -63,13 +63,10 @@ const CommentEditor = ({
const isUserLearner = useSelector(selectIsUserLearner);
const contentCreationRateLimited = useSelector(selectContentCreationRateLimited);
const postStatus = useSelector(selectPostStatus);
+ const [captchaError, setCaptchaError] = useState('');
const shouldRequireCaptcha = !id && captchaSettings.enabled && isUserLearner;
- const captchaValidation = {
- recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
- };
-
const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& author !== authenticatedUser.username
@@ -82,15 +79,12 @@ const CommentEditor = ({
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
- ...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
...editReasonCodeValidation,
- ...(shouldRequireCaptcha ? captchaValidation : {}),
});
const initialValues = {
comment: editorContent,
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
- recaptchaToken: '',
};
useEffect(() => {
@@ -99,20 +93,8 @@ const CommentEditor = ({
}
}, [contentCreationRateLimited, id]);
- const handleCaptchaChange = useCallback((token, setFieldValue) => {
- setFieldValue('recaptchaToken', token || '');
- }, []);
-
- const handleCaptchaExpired = useCallback((setFieldValue) => {
- setFieldValue('recaptchaToken', '');
- }, []);
-
const handleCloseEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
- // Reset CAPTCHA when hiding editor
- if (recaptchaRef.current) {
- recaptchaRef.current.reset();
- }
}, [initialValues]);
const deleteEditorContent = useCallback(async () => {
@@ -125,13 +107,14 @@ const CommentEditor = ({
}, [parentId, id, threadId, setDraftComments, setDraftResponses]);
useEffect(() => {
- if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting) {
+ if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting && !captchaError) {
onCloseEditor();
}
}, [postStatus, isSubmitting]);
const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
setIsSubmitting(true);
+ let recaptchaToken;
if (id) {
const payload = {
...values,
@@ -139,7 +122,20 @@ const CommentEditor = ({
};
await dispatch(editComment(id, payload));
} else {
- await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? values.recaptchaToken : ''));
+ if (shouldRequireCaptcha && executeRecaptcha) {
+ try {
+ recaptchaToken = await executeRecaptcha('submit_post');
+ if (!recaptchaToken) {
+ setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
+ return;
+ }
+ } catch (error) {
+ setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
+ return;
+ }
+ setCaptchaError('');
+ }
+ await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? recaptchaToken : ''));
}
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) {
@@ -147,7 +143,7 @@ const CommentEditor = ({
}
handleCloseEditor(resetForm);
deleteEditorContent();
- }, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]);
+ }, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha, executeRecaptcha]);
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${id || parentId || threadId}`;
@@ -194,7 +190,6 @@ const CommentEditor = ({
handleBlur,
handleChange,
resetForm,
- setFieldValue,
}) => (