diff --git a/api-server/server/boot/donate.js b/api-server/server/boot/donate.js index 2cbd3058c3..18b48ab28d 100644 --- a/api-server/server/boot/donate.js +++ b/api-server/server/boot/donate.js @@ -1,5 +1,6 @@ import Stripe from 'stripe'; import debug from 'debug'; +import crypto from 'crypto'; import { isEmail, isNumeric } from 'validator'; import keys from '../../../config/secrets'; @@ -108,18 +109,12 @@ export default function donateBoot(app, done) { function createStripeDonation(req, res) { const { user, body } = req; - if (!user) { + if (!user || !body) { return res .status(500) .send({ error: 'User must be signed in for this request.' }); } - if (!body || !body.amount || !body.duration) { - return res.status(500).send({ - error: 'The donation form had invalid values for this submission.' - }); - } - const { amount, duration, @@ -218,12 +213,54 @@ export default function donateBoot(app, done) { }); } + function createHmacHash(req, res) { + const { user, body } = req; + + if (!user || !body) { + return res + .status(500) + .send({ error: 'User must be signed in for this request.' }); + } + + const { email } = body; + + if (!isEmail('' + email)) { + return res + .status(500) + .send({ error: 'The email is invalid for this request.' }); + } + + if (!user.donationEmails.includes(email)) { + return res.status(500).send({ + error: `User does not have the email: ${email} associated with their donations.` + }); + } + + log(`creating HMAC hash for ${email}`); + return Promise.resolve(email) + .then(email => + crypto + .createHmac('sha256', keys.servicebot.hmacKey) + .update(email) + .digest('hex') + ) + .then(hash => res.status(200).json({ hash })) + .catch(() => + res + .status(500) + .send({ error: 'Donation failed due to a server error.' }) + ); + } + const pubKey = keys.stripe.public; const secKey = keys.stripe.secret; - const secretInvalid = !secKey || secKey === 'sk_from_stipe_dashboard'; - const publicInvalid = !pubKey || pubKey === 'pk_from_stipe_dashboard'; + const hmacKey = keys.servicebot.hmacKey; + const secretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard'; + const publicInvalid = !pubKey || pubKey === 'pk_from_stripe_dashboard'; + const hmacKeyInvalid = + !hmacKey || hmacKey === 'secret_key_from_servicebot_dashboard'; - if (secretInvalid || publicInvalid) { + if (secretInvalid || publicInvalid || hmacKeyInvalid) { if (process.env.FREECODECAMP_NODE_ENV === 'production') { throw new Error('Stripe API keys are required to boot the server!'); } @@ -231,6 +268,7 @@ export default function donateBoot(app, done) { done(); } else { api.post('/charge-stripe', createStripeDonation); + api.post('/create-hmac-hash', createHmacHash); donateRouter.use('/donate', api); app.use(donateRouter); app.use('/internal', donateRouter); diff --git a/api-server/server/utils/publicUserProps.js b/api-server/server/utils/publicUserProps.js index ec7df9f9b5..71becb1ac3 100644 --- a/api-server/server/utils/publicUserProps.js +++ b/api-server/server/utils/publicUserProps.js @@ -51,7 +51,8 @@ export const userPropsForSession = [ 'completedProjectCount', 'completedCertCount', 'completedLegacyCertCount', - 'acceptedPrivacyTerms' + 'acceptedPrivacyTerms', + 'donationEmails' ]; export function normaliseUserFields(user) { diff --git a/client/src/components/Donation/components/DonateCompletion.js b/client/src/components/Donation/components/DonateCompletion.js index 99718a160a..9637d423d8 100644 --- a/client/src/components/Donation/components/DonateCompletion.js +++ b/client/src/components/Donation/components/DonateCompletion.js @@ -42,7 +42,7 @@ function DonateCompletion({ processing, reset, success, error = null }) {

You can update your supporter status at any time from the 'manage - your existing donation' section on this page. + your existing donation' section below on this page.

)} diff --git a/client/src/components/Donation/components/DonateForm.js b/client/src/components/Donation/components/DonateForm.js index 257637a67b..f00c2c58db 100644 --- a/client/src/components/Donation/components/DonateForm.js +++ b/client/src/components/Donation/components/DonateForm.js @@ -30,6 +30,7 @@ const numToCommas = num => num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); const propTypes = { + enableDonationSettingsPage: PropTypes.func.isRequired, isDonating: PropTypes.bool, isSignedIn: PropTypes.bool, navigate: PropTypes.func.isRequired, @@ -204,7 +205,7 @@ class DonateForm extends Component { } renderDonationOptions() { - const { stripe } = this.props; + const { stripe, enableDonationSettingsPage } = this.props; const { donationAmount, donationDuration, @@ -242,6 +243,7 @@ class DonateForm extends Component { @@ -288,18 +290,6 @@ class DonateForm extends Component { this.renderDonationOptions() )} - - -

Manage your existing donation

- - - - - ); } diff --git a/client/src/components/Donation/components/DonateFormChildViewForHOC.js b/client/src/components/Donation/components/DonateFormChildViewForHOC.js index 39ab2a896c..0c07909c90 100644 --- a/client/src/components/Donation/components/DonateFormChildViewForHOC.js +++ b/client/src/components/Donation/components/DonateFormChildViewForHOC.js @@ -21,6 +21,7 @@ const propTypes = { donationAmount: PropTypes.number.isRequired, donationDuration: PropTypes.string.isRequired, email: PropTypes.string, + enableDonationSettingsPage: PropTypes.func.isRequired, getDonationButtonLabel: PropTypes.func.isRequired, hideAmountOptionsCB: PropTypes.func.isRequired, isSignedIn: PropTypes.bool, @@ -119,6 +120,7 @@ class DonateFormChildViewForHOC extends Component { } postDonation(token) { + const { enableDonationSettingsPage } = this.props; const { donationAmount: amount, donationDuration: duration } = this.state; this.setState(state => ({ ...state, @@ -144,6 +146,7 @@ class DonateFormChildViewForHOC extends Component { error: data.error ? data.error : null } })); + enableDonationSettingsPage(); }) .catch(error => { const data = diff --git a/client/src/components/Donation/components/DonateServicebotEmbed.js b/client/src/components/Donation/components/DonateServicebotEmbed.js new file mode 100644 index 0000000000..6079bed49e --- /dev/null +++ b/client/src/components/Donation/components/DonateServicebotEmbed.js @@ -0,0 +1,71 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { servicebotId } from '../../../../config/env.json'; +import { servicebotScriptLoader } from '../../../utils/scriptLoaders'; + +import '../Donation.css'; + +const propTypes = { + email: PropTypes.string.isRequired, + hash: PropTypes.string.isRequired +}; + +export class DonationServicebotEmbed extends Component { + constructor(...props) { + super(...props); + + this.state = { + email: this.props.email, + hash: this.props.hash + }; + + this.setServiceBotConfig = this.setServiceBotConfig.bind(this); + } + + setServiceBotConfig() { + const { email, hash } = this.state; + /* eslint-disable camelcase */ + window.servicebotSettings = { + type: 'portal', + servicebot_id: servicebotId, + service: 'freeCodeCamp.org', + email, + hash, + options: { + cancel_now: true, + disableCoupon: true, + forceCard: true, + disableTiers: [ + 'Monthly $10 Donation - Unavailable', + 'Monthly $3 Donation - Unavailable' + ], + card: { + hideName: true, + hideAddress: true, + hideCountryPostal: true + }, + messageOnCancel: `Thanks again for supporting our tiny nonprofit. We are helping millions of people around the world learn to code for free. Please confirm: are you certain you want to stop your donation?` + } + }; + /* eslint-enable camelcase */ + } + + componentDidMount() { + servicebotScriptLoader(); + } + + render() { + this.setServiceBotConfig(); + return ( +
+
+
+ ); + } +} + +DonationServicebotEmbed.displayName = 'DonationServicebotEmbed'; +DonationServicebotEmbed.propTypes = propTypes; + +export default DonationServicebotEmbed; diff --git a/client/src/pages/donate.js b/client/src/pages/donate.js index 8fbb5797a4..309fb2a1fc 100644 --- a/client/src/pages/donate.js +++ b/client/src/pages/donate.js @@ -3,32 +3,40 @@ import Helmet from 'react-helmet'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { Grid, Row, Col } from '@freecodecamp/react-bootstrap'; +import { Grid, Row, Col, Button } from '@freecodecamp/react-bootstrap'; import { stripePublicKey } from '../../config/env.json'; import { Spacer, Loader } from '../components/helpers'; import DonateForm from '../components/Donation/components/DonateForm'; import DonateText from '../components/Donation/components/DonateText'; -import { signInLoadingSelector } from '../redux'; +import { signInLoadingSelector, userSelector } from '../redux'; import { stripeScriptLoader } from '../utils/scriptLoaders'; +const propTypes = { + isDonating: PropTypes.bool, + showLoading: PropTypes.bool.isRequired +}; + const mapStateToProps = createSelector( + userSelector, signInLoadingSelector, - showLoading => ({ + ({ isDonating }, showLoading) => ({ + isDonating, showLoading }) ); -const propTypes = { - showLoading: PropTypes.bool.isRequired -}; - export class DonatePage extends Component { constructor(...props) { super(...props); this.state = { - stripe: null + stripe: null, + enableSettings: false }; + + this.enableDonationSettingsPage = this.enableDonationSettingsPage.bind( + this + ); this.handleStripeLoad = this.handleStripeLoad.bind(this); } @@ -60,9 +68,14 @@ export class DonatePage extends Component { })); } + enableDonationSettingsPage(enableSettings = true) { + this.setState({ enableSettings }); + } + render() { const { stripe } = this.state; - const { showLoading } = this.props; + const { showLoading, isDonating } = this.props; + const { enableSettings } = this.state; if (showLoading) { return ; @@ -81,7 +94,32 @@ export class DonatePage extends Component { - + + + + +

Manage your existing donation

+ {[ + `Update your existing donation`, + `Download donation receipts` + ].map(donationSettingOps => ( +
+ + +
+ ))} + +
diff --git a/client/src/pages/donation/settings.js b/client/src/pages/donation/settings.js new file mode 100644 index 0000000000..8325eab05a --- /dev/null +++ b/client/src/pages/donation/settings.js @@ -0,0 +1,209 @@ +import React, { Component, Fragment } from 'react'; +import Helmet from 'react-helmet'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { Grid, Row, Col, Button } from '@freecodecamp/react-bootstrap'; +import { uniq } from 'lodash'; + +import { apiLocation } from '../../../config/env.json'; +import { postCreateHmacHash } from '../../utils/ajax'; +import { + signInLoadingSelector, + userSelector, + hardGoTo as navigate, + isSignedInSelector +} from '../../redux'; +// eslint-disable-next-line max-len +import DonateServicebotEmbed from '../../components/Donation/components/DonateServicebotEmbed'; +import { Loader, Spacer, Link } from '../../components/helpers'; + +const propTypes = { + donationEmails: PropTypes.array, + email: PropTypes.string, + isDonating: PropTypes.bool, + isSignedIn: PropTypes.bool, + navigate: PropTypes.func.isRequired, + showLoading: PropTypes.bool.isRequired +}; + +const mapStateToProps = createSelector( + isSignedInSelector, + userSelector, + signInLoadingSelector, + (isSignedIn, { email, isDonating, donationEmails }, showLoading) => ({ + isSignedIn, + email, + isDonating, + donationEmails, + showLoading + }) +); + +const mapDispatchToProps = { + navigate +}; + +export class DonationSettingsPage extends Component { + constructor(...props) { + super(...props); + + this.state = { + hash: null, + currentSettingsEmail: null + }; + + this.getEmailHmacHash = this.getEmailHmacHash.bind(this); + this.handleSelectDonationEmail = this.handleSelectDonationEmail.bind(this); + } + + getEmailHmacHash(currentSettingsEmail) { + return postCreateHmacHash({ + email: currentSettingsEmail + }) + .then(response => { + const data = response && response.data; + this.setState({ hash: '' + data.hash, currentSettingsEmail }); + }) + .catch(error => { + const data = + error.response && error.response.data + ? error.response.data + : { + error: + 'Something is not right. Please contact team@freecodecamp.org' + }; + console.error(data.error); + }); + } + + handleSelectDonationEmail(e) { + e.preventDefault(); + this.setState({ hash: null, currentSettingsEmail: null }); + this.getEmailHmacHash(e.currentTarget.value); + } + + renderServicebotEmbed() { + const { currentSettingsEmail, hash } = this.state; + if (!hash && !currentSettingsEmail) { + return null; + } + return ( +
+ + +
+ ); + } + + renderDonationEmailsList() { + const { donationEmails } = this.props; + return ( +
+ {uniq(donationEmails).map((email, index) => ( +
+ + +
+ ))} +
+ ); + } + + render() { + const { showLoading, isSignedIn, isDonating, navigate } = this.props; + + if (showLoading) { + return ; + } + + if (!showLoading && !isSignedIn) { + navigate(`${apiLocation}/signin?returnTo=donation/settings`); + return ; + } + + if (!showLoading && !isDonating) { + navigate(`/donate`); + return ; + } + + return ( + + + + + + + + + + + + +

Manage your donations

+ +

+ Donations made using a credit or debit card +

+ +
+ + + {this.renderDonationEmailsList()} + + + + + {this.renderServicebotEmbed()} + + + + +
+

Donations made using PayPal

+

+ You can update your PayPal donation{' '} + + directly on PayPal + + . +

+ +
+ + +
+

Still need help?

+

+ If you can't see your donation here, forward a donation receipt + you have recieved in your email to team@freeCodeCamp.org and + tell us how we can help you with it. +

+ + +
+
+
+ ); + } +} + +DonationSettingsPage.displayName = 'DonationSettingsPage'; +DonationSettingsPage.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(DonationSettingsPage); diff --git a/client/src/utils/ajax.js b/client/src/utils/ajax.js index 17f8a6149d..2865d30a04 100644 --- a/client/src/utils/ajax.js +++ b/client/src/utils/ajax.js @@ -54,6 +54,10 @@ export function postChargeStripe(body) { return post(`/donate/charge-stripe`, body); } +export function postCreateHmacHash(body) { + return post(`/donate/create-hmac-hash`, body); +} + export function putUpdateLegacyCert(body) { return post('/update-my-projects', body); } diff --git a/client/src/utils/scriptLoaders.js b/client/src/utils/scriptLoaders.js index b10281297f..502c749137 100644 --- a/client/src/utils/scriptLoaders.js +++ b/client/src/utils/scriptLoaders.js @@ -19,6 +19,14 @@ export const stripeScriptLoader = onload => onload ); +export const servicebotScriptLoader = () => + scriptLoader( + 'servicebot-billing-settings-embed.js', + 'servicebot-billing-settings-embed.js', + true, + 'https://js.servicebot.io/embeds/servicebot-billing-settings-embed.js' + ); + export const mathJaxScriptLoader = () => scriptLoader( 'mathjax', diff --git a/config/env.js b/config/env.js index 5cb8d0891b..4d51068bf6 100644 --- a/config/env.js +++ b/config/env.js @@ -13,7 +13,8 @@ const { FORUM_PROXY: forumProxy, NEWS_PROXY: newsProxy, LOCALE: locale, - STRIPE_PUBLIC: stripePublicKey, + STRIPE_PUBLIC_KEY: stripePublicKey, + SERVICEBOT_ID: servicebotId, ALGOLIA_APP_ID: algoliaAppId, ALGOLIA_API_KEY: algoliaAPIKey } = process.env; @@ -30,6 +31,7 @@ const locations = { module.exports = Object.assign(locations, { locale, stripePublicKey, + servicebotId, algoliaAppId: !algoliaAppId || algoliaAppId === 'Algolia app id from dashboard' ? null diff --git a/config/secrets.js b/config/secrets.js index 4fa2b1a4dc..262067c5b9 100644 --- a/config/secrets.js +++ b/config/secrets.js @@ -30,8 +30,10 @@ const { ROLLBAR_APP_ID, ROLLBAR_CLIENT_ID, - STRIPE_PUBLIC, - STRIPE_SECRET + STRIPE_PUBLIC_KEY, + STRIPE_SECRET_KEY, + SERVICEBOT_ID, + SERVICEBOT_HMAC_SECRET_KEY } = process.env; module.exports = { @@ -92,7 +94,12 @@ module.exports = { }, stripe: { - public: STRIPE_PUBLIC, - secret: STRIPE_SECRET + public: STRIPE_PUBLIC_KEY, + secret: STRIPE_SECRET_KEY + }, + + servicebot: { + servicebotId: SERVICEBOT_ID, + hmacKey: SERVICEBOT_HMAC_SECRET_KEY } };