feat(donate): integrate servicebot
This commit is contained in:
parent
2cb8c16b28
commit
aeec1bb9e6
@ -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);
|
||||
|
@ -51,7 +51,8 @@ export const userPropsForSession = [
|
||||
'completedProjectCount',
|
||||
'completedCertCount',
|
||||
'completedLegacyCertCount',
|
||||
'acceptedPrivacyTerms'
|
||||
'acceptedPrivacyTerms',
|
||||
'donationEmails'
|
||||
];
|
||||
|
||||
export function normaliseUserFields(user) {
|
||||
|
@ -42,7 +42,7 @@ function DonateCompletion({ processing, reset, success, error = null }) {
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -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 {
|
||||
<DonateFormChildViewForHOC
|
||||
donationAmount={donationAmount}
|
||||
donationDuration={donationDuration}
|
||||
enableDonationSettingsPage={enableDonationSettingsPage}
|
||||
getDonationButtonLabel={this.getDonationButtonLabel}
|
||||
hideAmountOptionsCB={this.hideAmountOptionsCB}
|
||||
/>
|
||||
@ -288,18 +290,6 @@ class DonateForm extends Component {
|
||||
this.renderDonationOptions()
|
||||
)}
|
||||
</Col>
|
||||
<Col sm={10} smOffset={1} xs={12}>
|
||||
<Spacer size={2} />
|
||||
<h3 className='text-center'>Manage your existing donation</h3>
|
||||
<Button block={true} bsStyle='primary' disabled={true}>
|
||||
Update your existing donation
|
||||
</Button>
|
||||
<Spacer />
|
||||
<Button block={true} bsStyle='primary' disabled={true}>
|
||||
Download donation receipts
|
||||
</Button>
|
||||
<Spacer />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
@ -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 =
|
||||
|
@ -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 (
|
||||
<div className='fcc-servicebot-embed-portal'>
|
||||
<div id='servicebot-subscription-portal'></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DonationServicebotEmbed.displayName = 'DonationServicebotEmbed';
|
||||
DonationServicebotEmbed.propTypes = propTypes;
|
||||
|
||||
export default DonationServicebotEmbed;
|
@ -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 <Loader fullScreen={true} />;
|
||||
@ -81,7 +94,32 @@ export class DonatePage extends Component {
|
||||
</Row>
|
||||
<Row>
|
||||
<Col md={6}>
|
||||
<DonateForm stripe={stripe} />
|
||||
<DonateForm
|
||||
enableDonationSettingsPage={this.enableDonationSettingsPage}
|
||||
stripe={stripe}
|
||||
/>
|
||||
<Row>
|
||||
<Col sm={10} smOffset={1} xs={12}>
|
||||
<Spacer size={2} />
|
||||
<h3 className='text-center'>Manage your existing donation</h3>
|
||||
{[
|
||||
`Update your existing donation`,
|
||||
`Download donation receipts`
|
||||
].map(donationSettingOps => (
|
||||
<div key={donationSettingOps}>
|
||||
<Button
|
||||
block={true}
|
||||
bsStyle='primary'
|
||||
disabled={!isDonating && !enableSettings}
|
||||
href='/donation/settings'
|
||||
>
|
||||
{donationSettingOps}
|
||||
</Button>
|
||||
<Spacer />
|
||||
</div>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<DonateText />
|
||||
|
209
client/src/pages/donation/settings.js
Normal file
209
client/src/pages/donation/settings.js
Normal file
@ -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 (
|
||||
<div>
|
||||
<Spacer />
|
||||
<DonateServicebotEmbed email={currentSettingsEmail} hash={hash} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderDonationEmailsList() {
|
||||
const { donationEmails } = this.props;
|
||||
return (
|
||||
<div>
|
||||
{uniq(donationEmails).map((email, index) => (
|
||||
<div key={email + '-' + index}>
|
||||
<Button
|
||||
bsStyle='primary'
|
||||
className='btn btn-block'
|
||||
onClick={this.handleSelectDonationEmail}
|
||||
value={email}
|
||||
>
|
||||
{`Show donations for your ${email} email address`}
|
||||
</Button>
|
||||
<Spacer />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { showLoading, isSignedIn, isDonating, navigate } = this.props;
|
||||
|
||||
if (showLoading) {
|
||||
return <Loader fullScreen={true} />;
|
||||
}
|
||||
|
||||
if (!showLoading && !isSignedIn) {
|
||||
navigate(`${apiLocation}/signin?returnTo=donation/settings`);
|
||||
return <Loader fullScreen={true} />;
|
||||
}
|
||||
|
||||
if (!showLoading && !isDonating) {
|
||||
navigate(`/donate`);
|
||||
return <Loader fullScreen={true} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Helmet title='Manage your donation | freeCodeCamp.org' />
|
||||
<Grid>
|
||||
<Row>
|
||||
<Col sm={6} smOffset={3} xs={12}>
|
||||
<Spacer size={2} />
|
||||
<Button block={true} bsStyle='primary' href='/donate'>
|
||||
Go to donate page
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={8} smOffset={2} xs={12}>
|
||||
<Spacer />
|
||||
<h1 className='text-center'>Manage your donations</h1>
|
||||
<Spacer />
|
||||
<h3 className='text-center'>
|
||||
Donations made using a credit or debit card
|
||||
</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={6} smOffset={3} xs={12}>
|
||||
{this.renderDonationEmailsList()}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={8} smOffset={2} xs={12}>
|
||||
{this.renderServicebotEmbed()}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={8} smOffset={2} xs={12}>
|
||||
<hr />
|
||||
<h3 className='text-center'>Donations made using PayPal</h3>
|
||||
<p className='text-center'>
|
||||
You can update your PayPal donation{' '}
|
||||
<Link
|
||||
external={true}
|
||||
to='https://www.paypal.com/cgi-bin/webscr?cmd=_manage-paylist'
|
||||
>
|
||||
directly on PayPal
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={8} smOffset={2} xs={12}>
|
||||
<hr />
|
||||
<h3 className='text-center'>Still need help?</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<Spacer />
|
||||
</Col>
|
||||
</Row>
|
||||
</Grid>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DonationSettingsPage.displayName = 'DonationSettingsPage';
|
||||
DonationSettingsPage.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(DonationSettingsPage);
|
@ -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);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user