diff --git a/app/controllers/api/payment/braintree_controller.rb b/app/controllers/api/payment/braintree_controller.rb index 484e81d5d..abbdd9f0c 100644 --- a/app/controllers/api/payment/braintree_controller.rb +++ b/app/controllers/api/payment/braintree_controller.rb @@ -92,7 +92,7 @@ def valid_user?(user) def verify_bot action = 'donate/' + params[:page_id] - @captcha = Recaptcha3.new(token: params[:recaptacha_token], action: action) + @captcha = Recaptcha3.new(token: params[:recaptcha_token], action: action) unless @captcha.human? msg = @captcha.errors.present? ? @captcha.errors : 'Invalid request' diff --git a/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap b/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap index 7c1bb9530..10b7836a8 100644 --- a/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap +++ b/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap @@ -202,6 +202,7 @@ Thanks so much for everything you do!", "petition.sign_it": "Sign the petition", "petition.target_prefix": "TO", "petition.thank_you": "Thanks for adding your name to \\"{petition_title}\\"", + "recaptcha_branding_html": "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.", "recommend_pages.actions": "{action_count} actions", "recommend_pages.learn_more": "learn more", "recommend_pages.recommend_pages_title": "Here are other similar campaigns we need your support on!", @@ -645,6 +646,7 @@ Thanks so much for everything you do!", "petition.sign_it": "Sign the petition", "petition.target_prefix": "TO", "petition.thank_you": "Thanks for adding your name to \\"{petition_title}\\"", + "recaptcha_branding_html": "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.", "recommend_pages.actions": "{action_count} actions", "recommend_pages.learn_more": "learn more", "recommend_pages.recommend_pages_title": "Here are other similar campaigns we need your support on!", diff --git a/app/javascript/components/Payment/Payment.css b/app/javascript/components/Payment/Payment.css index f8726034b..3ffeb5b68 100644 --- a/app/javascript/components/Payment/Payment.css +++ b/app/javascript/components/Payment/Payment.css @@ -46,6 +46,10 @@ padding-bottom: 15px; } +.Payment__fine-print .ReCaptchaBranding { + margin-top: 5px; +} + .Payment .gpay-button { width: 100%; height: 60px; @@ -91,8 +95,8 @@ box-shadow: none; } -@media(max-width: 700px) { +@media (max-width: 700px) { .PaymentExpressDonationConflict { width: 100%; } -} \ No newline at end of file +} diff --git a/app/javascript/components/Payment/Payment.js b/app/javascript/components/Payment/Payment.js index 2f78ec1ed..38b413221 100644 --- a/app/javascript/components/Payment/Payment.js +++ b/app/javascript/components/Payment/Payment.js @@ -6,6 +6,7 @@ import braintreeClient from 'braintree-web/client'; import dataCollector from 'braintree-web/data-collector'; import { isEmpty } from 'lodash'; import ee from '../../shared/pub_sub'; +import captcha from '../../shared/recaptcha'; import PayPal from '../Braintree/PayPal'; import BraintreeCardFields from '../Braintree/BraintreeCardFields'; @@ -14,6 +15,7 @@ import WelcomeMember from '../WelcomeMember/WelcomeMember'; import DonateButton from '../DonateButton'; import Checkbox from '../Checkbox/Checkbox'; import ShowIf from '../ShowIf'; +import ReCaptchaBranding from '../ReCaptchaBranding'; import { resetMember } from '../../state/member/reducer'; import { changeStep, @@ -26,7 +28,6 @@ import ExpressDonation from '../ExpressDonation/ExpressDonation'; // Styles import './Payment.css'; -const RECAPTCHA_SITE_KEY = window.champaign.configuration.recaptcha3.siteKey; const BRAINTREE_TOKEN_URL = process.env.BRAINTREE_TOKEN_URL || '/api/payment/braintree/token'; @@ -48,7 +49,6 @@ export class Payment extends Component { }, errors: [], waitingForGoCardless: false, - recaptacha_token: null, }; } @@ -85,27 +85,8 @@ export class Payment extends Component { console.warn('could not fetch Braintree token'); }); this.bindGlobalEvents(); - if (RECAPTCHA_SITE_KEY) this.loadReCaptcha(); } - loadReCaptcha() { - try { - grecaptcha - .execute(RECAPTCHA_SITE_KEY, { action: `donate/${this.props.page.id}` }) - .then( - token => { - this.setState({ - recaptacha_token: token, - }); - }, - error => { - console.warn('Error fetching recaptcha token ', error); - } - ); - } catch (error) { - console.warn('Error trying to execute grecaptcha.', error); - } - } bindGlobalEvents() { ee.on('fundraiser:actions:make_payment', this.makePayment); } @@ -252,13 +233,16 @@ export class Payment extends Component { } }; - submit = data => { + submit = async data => { + const recaptcha_action = `donate/${this.props.page.id}`; + const recaptcha_token = await captcha.execute({ action: recaptcha_action }); + const payload = { ...this.donationData(), payment_method_nonce: data.nonce, device_data: this.state.deviceData, - recaptacha_token: this.state.recaptacha_token, - recaptacha_action: `donate/${this.props.page.id}`, + recaptcha_token, + recaptcha_action, }; this.emitTransactionSubmitted(); @@ -327,7 +311,6 @@ export class Payment extends Component { } else { errors = []; } - if (RECAPTCHA_SITE_KEY) this.loadReCaptcha(); this.setState({ errors: errors }); this.onError(response); }; @@ -466,6 +449,7 @@ export class Payment extends Component { For further information, please contact info@sumofus.org. `} /> + {this.props.showDirectDebit && ( diff --git a/app/javascript/components/ReCaptchaBranding.tsx b/app/javascript/components/ReCaptchaBranding.tsx new file mode 100644 index 000000000..4269824b7 --- /dev/null +++ b/app/javascript/components/ReCaptchaBranding.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { FormattedHTMLMessage } from 'react-intl'; + +const ReCaptchaBranding = () => ( +

+ + {'This site is protected by reCAPTCHA and the Google '} + Privacy Policy + {' and '} + Terms of Service + {' apply.'} +

+ } + /> +

+); + +export default ReCaptchaBranding; diff --git a/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap b/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap index 8ba6fec5e..41898f1ba 100644 --- a/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap +++ b/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap @@ -188,6 +188,7 @@ Thanks so much for everything you do!", "petition.sign_it": "Sign the petition", "petition.target_prefix": "TO", "petition.thank_you": "Thanks for adding your name to \\"{petition_title}\\"", + "recaptcha_branding_html": "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.", "recommend_pages.actions": "{action_count} actions", "recommend_pages.learn_more": "learn more", "recommend_pages.recommend_pages_title": "Here are other similar campaigns we need your support on!", diff --git a/app/javascript/shared/recaptcha/index.ts b/app/javascript/shared/recaptcha/index.ts new file mode 100644 index 000000000..59418b490 --- /dev/null +++ b/app/javascript/shared/recaptcha/index.ts @@ -0,0 +1,57 @@ +import './recaptcha.css'; + +const MAX_RETRIES = 4; +export interface IReCaptchaInstance { + ready: (cb: () => any) => void; + execute: (siteKey: string, options: IExecuteOptions) => Promise; +} +interface IExecuteOptions { + action: string; +} + +export const isReady = () => { + return window.grecaptcha != null && window.grecaptcha.execute != null; +}; + +export const load = (): Promise => { + if (isReady()) { + return Promise.resolve(window.grecaptcha as IReCaptchaInstance); + } + + const siteKey = window.champaign.configuration.recaptcha3.siteKey; + + const src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`; + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = src; + script.async = true; + script.defer = true; + document.body.appendChild(script); + + return new Promise((resolve, reject) => { + let retries = 0; + const id = setTimeout(() => { + if (window.grecaptcha) { + clearTimeout(id); + return resolve(window.grecaptcha); + } else if (retries >= MAX_RETRIES) { + clearTimeout(id); + return reject(new Error('Could not load reCAPTCHA')); + } else { + retries = retries + 1; + } + }, 400); + }); +}; + +export const execute = async (options: IExecuteOptions): Promise => { + const siteKey = window.champaign.configuration.recaptcha3.siteKey; + const captcha = await load(); + return captcha.execute(siteKey, options); +}; + +export default { + load, + isReady, + execute, +}; diff --git a/app/javascript/shared/recaptcha/recaptcha.css b/app/javascript/shared/recaptcha/recaptcha.css new file mode 100644 index 000000000..545f6b084 --- /dev/null +++ b/app/javascript/shared/recaptcha/recaptcha.css @@ -0,0 +1,3 @@ +.grecaptcha-badge { + visibility: hidden; +} diff --git a/app/javascript/window.d.ts b/app/javascript/window.d.ts index 594f2efcf..ce04a2993 100644 --- a/app/javascript/window.d.ts +++ b/app/javascript/window.d.ts @@ -4,22 +4,26 @@ import { Store } from 'redux'; import { Fundraiser } from './plugins/fundraiser'; import { Petition } from './plugins/petition'; import Plugin, { IPluginConfig } from './plugins/plugin'; +import { IReCaptchaInstance } from './shared/recaptcha'; import { IAppState, IFormField } from './types'; declare global { // tslint:disable-next-line:interface-name interface Window { + grecaptcha?: IReCaptchaInstance; champaign: IChampaignGlobalObject; I18n: II18n & typeof I18n; store: Store; ee: EventEmitter; } } - interface IChampaignGlobalObject { configuration: { environment: string; defaultCurrency: string; + recaptcha3: { + siteKey: string; + }; [key: string]: any; }; page: IChampaignPage; diff --git a/config/locales/member_facing.de.yml b/config/locales/member_facing.de.yml index b150ffe98..d429124a5 100644 --- a/config/locales/member_facing.de.yml +++ b/config/locales/member_facing.de.yml @@ -262,3 +262,4 @@ de: message: 'Cookies erleichtern die Nutzung unserer Webseite.' dismiss_button_text: 'Einverstanden, weiter' privacy_policy_link_text: 'Datenschutz' + recaptcha_branding_html: 'Diese Website ist durch reCAPTCHA geschützt und es gelten die Datenschutzbestimmungen and Nutzungsbedingungen von Google.' diff --git a/config/locales/member_facing.en.yml b/config/locales/member_facing.en.yml index 26ec61d89..23aae7a57 100644 --- a/config/locales/member_facing.en.yml +++ b/config/locales/member_facing.en.yml @@ -269,3 +269,4 @@ en: message: 'Cookies are important to the proper functioning of this site.' dismiss_button_text: 'Agree & Proceed' privacy_policy_link_text: 'Privacy Policy' + recaptcha_branding_html: 'This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.' diff --git a/config/locales/member_facing.es.yml b/config/locales/member_facing.es.yml index 9263d1d3b..6454ffac0 100644 --- a/config/locales/member_facing.es.yml +++ b/config/locales/member_facing.es.yml @@ -270,3 +270,4 @@ es: message: 'Las cookies son importantes para el funcionamiento de este sitio.' dismiss_button_text: 'Aceptar & Continuar' privacy_policy_link_text: 'Política de privacidad' + recaptcha_branding_html: 'Este sitio está protegido por reCAPTCHA y se aplican la Política de Privacidad y los Términos de Servicio de Google.' diff --git a/config/locales/member_facing.fr.yml b/config/locales/member_facing.fr.yml index 5e2184b5c..caa62fc17 100644 --- a/config/locales/member_facing.fr.yml +++ b/config/locales/member_facing.fr.yml @@ -248,8 +248,8 @@ fr: opt_in_reason: "

Nous demandons aux membres SumOfUs de confirmer qu’ils/elles souhaitent toujours recevoir nos courriels à ce sujet et concernant d’autres campagnes urgentes.

Souhaitez-vous confirmer aujourd’hui?

" decline: "Pas maintenant" accept: "Oui" - cookie_consent: message: 'Les cookies sont importants pour le bon fonctionnement de notre site.' dismiss_button_text: 'Accepter et continuer' privacy_policy_link_text: 'Mentions Légales' + recaptcha_branding_html: "Ce site est protégé par reCAPTCHA. Les règles de confidentialité et les conditions d'utilisation de Google s'appliquent." \ No newline at end of file