Merge pull request #3765 from picklesrus/captcha-component

Move reCaptcha code to a component
This commit is contained in:
picklesrus 2020-03-27 09:18:22 -04:00 committed by GitHub
commit 7548253b17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 154 additions and 71 deletions

View file

@ -0,0 +1,76 @@
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
class Captcha extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'setCaptchaRef',
'onCaptchaLoad',
'executeCaptcha'
]);
}
componentDidMount () {
if (window.grecaptcha) {
this.onCaptchaLoad();
} else {
// If grecaptcha doesn't exist on window, we havent loaded the captcha js yet. Load it.
// ReCaptcha calls a callback when the grecatpcha object is usable. That callback
// needs to be global so set it on the window.
window.grecaptchaOnLoad = this.onCaptchaLoad;
// Load Google ReCaptcha script.
const script = document.createElement('script');
script.async = true;
script.onerror = this.props.onCaptchaError;
script.src = `https://www.recaptcha.net/recaptcha/api.js?onload=grecaptchaOnLoad&render=explicit&hl=${window._locale}`;
document.body.appendChild(script);
}
}
componentWillUnmount () {
window.grecaptchaOnLoad = null;
}
onCaptchaLoad () {
// Let the owner of this component do some work
// when captcha is done loading (e.g. enabling a button)
this.props.onCaptchaLoad();
this.grecaptcha = window.grecaptcha;
if (!this.grecaptcha) {
// According to the reCaptcha documentation, this callback shouldn't get
// called unless window.grecaptcha exists. This is just here to be extra defensive.
this.props.onCaptchaError();
return;
}
this.widgetId = this.grecaptcha.render(this.captchaRef,
{
callback: this.props.onCaptchaSolved,
sitekey: process.env.RECAPTCHA_SITE_KEY
},
true);
}
setCaptchaRef (ref) {
this.captchaRef = ref;
}
executeCaptcha () {
this.grecaptcha.execute(this.widgetId);
}
render () {
return (
<div
className="g-recaptcha"
data-badge="bottomright"
data-sitekey={process.env.RECAPTCHA_SITE_KEY}
data-size="invisible"
ref={this.setCaptchaRef}
/>
);
}
}
Captcha.propTypes = {
onCaptchaError: PropTypes.func.isRequired,
onCaptchaLoad: PropTypes.func.isRequired,
onCaptchaSolved: PropTypes.func.isRequired
};
module.exports = Captcha;

View file

@ -11,7 +11,7 @@ const JoinFlowStep = require('./join-flow-step.jsx');
const FormikInput = require('../../components/formik-forms/formik-input.jsx');
const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx');
const InfoButton = require('../info-button/info-button.jsx');
const Captcha = require('../../components/captcha/captcha.jsx');
require('./join-flow-steps.scss');
class EmailStep extends React.Component {
@ -24,8 +24,8 @@ class EmailStep extends React.Component {
'validateEmailRemotelyWithCache',
'validateForm',
'setCaptchaRef',
'captchaSolved',
'onCaptchaLoad'
'handleCaptchaSolved',
'handleCaptchaLoad'
]);
this.state = {
captchaIsLoading: true
@ -40,43 +40,12 @@ class EmailStep extends React.Component {
}
// automatically start with focus on username field
if (this.emailInput) this.emailInput.focus();
if (window.grecaptcha) {
this.onCaptchaLoad();
} else {
// If grecaptcha doesn't exist on window, we havent loaded the captcha js yet. Load it.
// ReCaptcha calls a callback when the grecatpcha object is usable. That callback
// needs to be global so set it on the window.
window.grecaptchaOnLoad = this.onCaptchaLoad;
// Load Google ReCaptcha script.
const script = document.createElement('script');
script.async = true;
script.onerror = this.props.onCaptchaError;
script.src = `https://www.recaptcha.net/recaptcha/api.js?onload=grecaptchaOnLoad&render=explicit&hl=${window._locale}`;
document.body.appendChild(script);
}
}
componentWillUnmount () {
window.grecaptchaOnLoad = null;
}
handleSetEmailRef (emailInputRef) {
this.emailInput = emailInputRef;
}
onCaptchaLoad () {
handleCaptchaLoad () {
this.setState({captchaIsLoading: false});
this.grecaptcha = window.grecaptcha;
if (!this.grecaptcha) {
// According to the reCaptcha documentation, this callback shouldn't get
// called unless window.grecaptcha exists. This is just here to be extra defensive.
this.props.onCaptchaError();
return;
}
this.widgetId = this.grecaptcha.render(this.captchaRef,
{
callback: this.captchaSolved,
sitekey: process.env.RECAPTCHA_SITE_KEY
},
true);
}
// simple function to memoize remote requests for usernames
validateEmailRemotelyWithCache (email) {
@ -116,9 +85,9 @@ class EmailStep extends React.Component {
// Change set submitting to false so that if the user clicks out of
// the captcha, the button is clickable again (instead of a disabled button with a spinner).
this.formikBag.setSubmitting(false);
this.grecaptcha.execute(this.widgetId);
this.captchaRef.executeCaptcha();
}
captchaSolved (token) {
handleCaptchaSolved (token) {
// Now thatcaptcha is done, we can tell Formik we're submitting.
this.formikBag.setSubmitting(true);
this.formData['g-recaptcha-response'] = token;
@ -224,12 +193,11 @@ class EmailStep extends React.Component {
name="subscribe"
/>
</div>
<div
className="g-recaptcha"
data-badge="bottomright"
data-sitekey={process.env.RECAPTCHA_SITE_KEY}
data-size="invisible"
<Captcha
ref={this.setCaptchaRef}
onCaptchaError={this.props.onCaptchaError}
onCaptchaLoad={this.handleCaptchaLoad}
onCaptchaSolved={this.handleCaptchaSolved}
/>
</JoinFlowStep>
);

View file

@ -0,0 +1,57 @@
const React = require('react');
const enzyme = require('enzyme');
const Captcha = require('../../../src/components/captcha/captcha.jsx');
describe('Captcha test', () => {
global.grecaptcha = {
execute: jest.fn(),
render: jest.fn()
};
test('Captcha load calls props captchaOnLoad', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.shallow(<Captcha
{...props}
/>);
wrapper.instance().onCaptchaLoad();
expect(global.grecaptcha.render).toHaveBeenCalled();
expect(props.onCaptchaLoad).toHaveBeenCalled();
});
test('Captcha execute calls grecatpcha execute', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.shallow(<Captcha
{...props}
/>);
wrapper.instance().executeCaptcha();
expect(global.grecaptcha.execute).toHaveBeenCalled();
});
test('Captcha load calls props captchaOnLoad', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.shallow(<Captcha
{...props}
/>);
wrapper.instance().onCaptchaLoad();
expect(global.grecaptcha.render).toHaveBeenCalled();
expect(props.onCaptchaLoad).toHaveBeenCalled();
});
test('Captcha renders the div google wants', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.mount(<Captcha
{...props}
/>);
expect(wrapper.find('div.g-recaptcha')).toHaveLength(1);
});
});

View file

@ -115,9 +115,9 @@ describe('EmailStep test', () => {
const formikBag = {
setSubmitting: jest.fn()
};
global.grecaptcha = {
execute: jest.fn(),
render: jest.fn()
const captchaRef = {
executeCaptcha: jest.fn()
};
const formData = {item1: 'thing', item2: 'otherthing'};
const intlWrapper = shallowWithIntl(
@ -125,13 +125,12 @@ describe('EmailStep test', () => {
{...defaultProps()}
/>);
const emailStepWrapper = intlWrapper.dive();
emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state
emailStepWrapper.instance().setCaptchaRef(captchaRef);
emailStepWrapper.instance().handleValidSubmit(formData, formikBag);
expect(formikBag.setSubmitting).toHaveBeenCalledWith(false);
expect(global.grecaptcha.execute).toHaveBeenCalled();
expect(captchaRef.executeCaptcha).toHaveBeenCalled();
});
test('captchaSolved sets token and goes to next step', () => {
@ -141,9 +140,8 @@ describe('EmailStep test', () => {
const formikBag = {
setSubmitting: jest.fn()
};
global.grecaptcha = {
execute: jest.fn(),
render: jest.fn()
const captchaRef = {
executeCaptcha: jest.fn()
};
const formData = {item1: 'thing', item2: 'otherthing'};
const intlWrapper = shallowWithIntl(
@ -154,11 +152,11 @@ describe('EmailStep test', () => {
const emailStepWrapper = intlWrapper.dive();
// Call these to setup captcha.
emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state
emailStepWrapper.instance().setCaptchaRef(captchaRef); // to setup catpcha state
emailStepWrapper.instance().handleValidSubmit(formData, formikBag);
const captchaToken = 'abcd';
emailStepWrapper.instance().captchaSolved(captchaToken);
emailStepWrapper.instance().handleCaptchaSolved(captchaToken);
// Make sure captchaSolved calls onNextStep with formData that has
// a captcha token and left everything else in the object in place.
expect(props.onNextStep).toHaveBeenCalledWith(
@ -170,29 +168,13 @@ describe('EmailStep test', () => {
expect(formikBag.setSubmitting).toHaveBeenCalledWith(true);
});
test('Captcha load error calls error function', () => {
const props = {
onCaptchaError: jest.fn()
};
// Set this to null to force an error.
global.grecaptcha = null;
const intlWrapper = shallowWithIntl(
<EmailStep
{...defaultProps()}
{...props}
/>
);
const emailStepWrapper = intlWrapper.dive();
emailStepWrapper.instance().onCaptchaLoad();
expect(props.onCaptchaError).toHaveBeenCalled();
});
test('Component logs analytics', () => {
const sendAnalyticsFn = jest.fn();
const onCaptchaError = jest.fn();
mountWithIntl(
<EmailStep
sendAnalytics={sendAnalyticsFn}
onCaptchaError={onCaptchaError}
/>);
expect(sendAnalyticsFn).toHaveBeenCalledWith('join-email');
});