mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2024-11-26 00:58:14 -05:00
Merge pull request #3765 from picklesrus/captcha-component
Move reCaptcha code to a component
This commit is contained in:
commit
7548253b17
4 changed files with 154 additions and 71 deletions
76
src/components/captcha/captcha.jsx
Normal file
76
src/components/captcha/captcha.jsx
Normal 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;
|
|
@ -11,7 +11,7 @@ const JoinFlowStep = require('./join-flow-step.jsx');
|
||||||
const FormikInput = require('../../components/formik-forms/formik-input.jsx');
|
const FormikInput = require('../../components/formik-forms/formik-input.jsx');
|
||||||
const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx');
|
const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx');
|
||||||
const InfoButton = require('../info-button/info-button.jsx');
|
const InfoButton = require('../info-button/info-button.jsx');
|
||||||
|
const Captcha = require('../../components/captcha/captcha.jsx');
|
||||||
require('./join-flow-steps.scss');
|
require('./join-flow-steps.scss');
|
||||||
|
|
||||||
class EmailStep extends React.Component {
|
class EmailStep extends React.Component {
|
||||||
|
@ -24,8 +24,8 @@ class EmailStep extends React.Component {
|
||||||
'validateEmailRemotelyWithCache',
|
'validateEmailRemotelyWithCache',
|
||||||
'validateForm',
|
'validateForm',
|
||||||
'setCaptchaRef',
|
'setCaptchaRef',
|
||||||
'captchaSolved',
|
'handleCaptchaSolved',
|
||||||
'onCaptchaLoad'
|
'handleCaptchaLoad'
|
||||||
]);
|
]);
|
||||||
this.state = {
|
this.state = {
|
||||||
captchaIsLoading: true
|
captchaIsLoading: true
|
||||||
|
@ -40,43 +40,12 @@ class EmailStep extends React.Component {
|
||||||
}
|
}
|
||||||
// automatically start with focus on username field
|
// automatically start with focus on username field
|
||||||
if (this.emailInput) this.emailInput.focus();
|
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) {
|
handleSetEmailRef (emailInputRef) {
|
||||||
this.emailInput = emailInputRef;
|
this.emailInput = emailInputRef;
|
||||||
}
|
}
|
||||||
onCaptchaLoad () {
|
handleCaptchaLoad () {
|
||||||
this.setState({captchaIsLoading: false});
|
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
|
// simple function to memoize remote requests for usernames
|
||||||
validateEmailRemotelyWithCache (email) {
|
validateEmailRemotelyWithCache (email) {
|
||||||
|
@ -116,9 +85,9 @@ class EmailStep extends React.Component {
|
||||||
// Change set submitting to false so that if the user clicks out of
|
// 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).
|
// the captcha, the button is clickable again (instead of a disabled button with a spinner).
|
||||||
this.formikBag.setSubmitting(false);
|
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.
|
// Now thatcaptcha is done, we can tell Formik we're submitting.
|
||||||
this.formikBag.setSubmitting(true);
|
this.formikBag.setSubmitting(true);
|
||||||
this.formData['g-recaptcha-response'] = token;
|
this.formData['g-recaptcha-response'] = token;
|
||||||
|
@ -224,12 +193,11 @@ class EmailStep extends React.Component {
|
||||||
name="subscribe"
|
name="subscribe"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Captcha
|
||||||
className="g-recaptcha"
|
|
||||||
data-badge="bottomright"
|
|
||||||
data-sitekey={process.env.RECAPTCHA_SITE_KEY}
|
|
||||||
data-size="invisible"
|
|
||||||
ref={this.setCaptchaRef}
|
ref={this.setCaptchaRef}
|
||||||
|
onCaptchaError={this.props.onCaptchaError}
|
||||||
|
onCaptchaLoad={this.handleCaptchaLoad}
|
||||||
|
onCaptchaSolved={this.handleCaptchaSolved}
|
||||||
/>
|
/>
|
||||||
</JoinFlowStep>
|
</JoinFlowStep>
|
||||||
);
|
);
|
||||||
|
|
57
test/unit/components/captcha.test.jsx
Normal file
57
test/unit/components/captcha.test.jsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -115,9 +115,9 @@ describe('EmailStep test', () => {
|
||||||
const formikBag = {
|
const formikBag = {
|
||||||
setSubmitting: jest.fn()
|
setSubmitting: jest.fn()
|
||||||
};
|
};
|
||||||
global.grecaptcha = {
|
|
||||||
execute: jest.fn(),
|
const captchaRef = {
|
||||||
render: jest.fn()
|
executeCaptcha: jest.fn()
|
||||||
};
|
};
|
||||||
const formData = {item1: 'thing', item2: 'otherthing'};
|
const formData = {item1: 'thing', item2: 'otherthing'};
|
||||||
const intlWrapper = shallowWithIntl(
|
const intlWrapper = shallowWithIntl(
|
||||||
|
@ -125,13 +125,12 @@ describe('EmailStep test', () => {
|
||||||
{...defaultProps()}
|
{...defaultProps()}
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
|
|
||||||
const emailStepWrapper = intlWrapper.dive();
|
const emailStepWrapper = intlWrapper.dive();
|
||||||
emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state
|
emailStepWrapper.instance().setCaptchaRef(captchaRef);
|
||||||
emailStepWrapper.instance().handleValidSubmit(formData, formikBag);
|
emailStepWrapper.instance().handleValidSubmit(formData, formikBag);
|
||||||
|
|
||||||
expect(formikBag.setSubmitting).toHaveBeenCalledWith(false);
|
expect(formikBag.setSubmitting).toHaveBeenCalledWith(false);
|
||||||
expect(global.grecaptcha.execute).toHaveBeenCalled();
|
expect(captchaRef.executeCaptcha).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('captchaSolved sets token and goes to next step', () => {
|
test('captchaSolved sets token and goes to next step', () => {
|
||||||
|
@ -141,9 +140,8 @@ describe('EmailStep test', () => {
|
||||||
const formikBag = {
|
const formikBag = {
|
||||||
setSubmitting: jest.fn()
|
setSubmitting: jest.fn()
|
||||||
};
|
};
|
||||||
global.grecaptcha = {
|
const captchaRef = {
|
||||||
execute: jest.fn(),
|
executeCaptcha: jest.fn()
|
||||||
render: jest.fn()
|
|
||||||
};
|
};
|
||||||
const formData = {item1: 'thing', item2: 'otherthing'};
|
const formData = {item1: 'thing', item2: 'otherthing'};
|
||||||
const intlWrapper = shallowWithIntl(
|
const intlWrapper = shallowWithIntl(
|
||||||
|
@ -154,11 +152,11 @@ describe('EmailStep test', () => {
|
||||||
|
|
||||||
const emailStepWrapper = intlWrapper.dive();
|
const emailStepWrapper = intlWrapper.dive();
|
||||||
// Call these to setup captcha.
|
// 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);
|
emailStepWrapper.instance().handleValidSubmit(formData, formikBag);
|
||||||
|
|
||||||
const captchaToken = 'abcd';
|
const captchaToken = 'abcd';
|
||||||
emailStepWrapper.instance().captchaSolved(captchaToken);
|
emailStepWrapper.instance().handleCaptchaSolved(captchaToken);
|
||||||
// Make sure captchaSolved calls onNextStep with formData that has
|
// Make sure captchaSolved calls onNextStep with formData that has
|
||||||
// a captcha token and left everything else in the object in place.
|
// a captcha token and left everything else in the object in place.
|
||||||
expect(props.onNextStep).toHaveBeenCalledWith(
|
expect(props.onNextStep).toHaveBeenCalledWith(
|
||||||
|
@ -170,29 +168,13 @@ describe('EmailStep test', () => {
|
||||||
expect(formikBag.setSubmitting).toHaveBeenCalledWith(true);
|
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', () => {
|
test('Component logs analytics', () => {
|
||||||
const sendAnalyticsFn = jest.fn();
|
const sendAnalyticsFn = jest.fn();
|
||||||
|
const onCaptchaError = jest.fn();
|
||||||
mountWithIntl(
|
mountWithIntl(
|
||||||
<EmailStep
|
<EmailStep
|
||||||
sendAnalytics={sendAnalyticsFn}
|
sendAnalytics={sendAnalyticsFn}
|
||||||
|
onCaptchaError={onCaptchaError}
|
||||||
/>);
|
/>);
|
||||||
expect(sendAnalyticsFn).toHaveBeenCalledWith('join-email');
|
expect(sendAnalyticsFn).toHaveBeenCalledWith('join-email');
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue