Merge branch 'master' into gdpr-email

This commit is contained in:
Wyatt Johnson
2018-05-03 15:18:22 -06:00
51 changed files with 1689 additions and 63 deletions
@@ -57,7 +57,7 @@ class OrganizationSettings extends React.Component {
}
const updater = { organizationContactEmail: { $set: email } };
const errorUpdater = { organizationEmail: { $set: error } };
const errorUpdater = { organizationContactEmail: { $set: error } };
this.props.updatePending({ updater, errorUpdater });
};
@@ -27,6 +27,7 @@ import {
getActionSummary,
iPerformedThisAction,
isCommentActive,
isCommentDeleted,
getShallowChanges,
} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
@@ -744,8 +745,14 @@ export default class Comment extends React.Component {
return (
<div className={rootClassName} id={id}>
{this.renderComment()}
{activeReplyBox === comment.id && this.renderReplyBox()}
{isCommentDeleted(comment) ? (
<CommentTombstone action="deleted" />
) : (
<div>
{this.renderComment()}
{activeReplyBox === comment.id && this.renderReplyBox()}
</div>
)}
{this.renderRepliesContainer()}
</div>
);
@@ -13,6 +13,8 @@ class CommentTombstone extends React.Component {
return t('framework.comment_is_ignored');
case 'reject':
return t('framework.comment_is_rejected');
case 'deleted':
return t('framework.comment_is_deleted');
default:
return t('framework.comment_is_hidden');
}
@@ -372,6 +372,7 @@ const slots = [
'streamTabsPrepend',
'streamTabPanes',
'streamFilter',
'stream',
];
const fragments = {
+8
View File
@@ -1,6 +1,7 @@
import { gql } from 'react-apollo';
import t from 'coral-framework/services/i18n';
import union from 'lodash/union';
import get from 'lodash/get';
import { capitalize } from 'coral-framework/helpers/strings';
import assignWith from 'lodash/assignWith';
import mapValues from 'lodash/mapValues';
@@ -221,6 +222,13 @@ export function isCommentActive(commentStatus) {
return ['NONE', 'ACCEPTED'].indexOf(commentStatus) >= 0;
}
export function isCommentDeleted(comment) {
return (
get(comment, 'body', null) === null ||
get(comment, 'deleted_at', null) !== null
);
}
export function getShallowChanges(a, b) {
return union(Object.keys(a), Object.keys(b)).filter(key => a[key] !== b[key]);
}
+41 -25
View File
@@ -8,7 +8,7 @@ const {
SET_USER_BAN_STATUS,
SET_USER_SUSPENSION_STATUS,
UPDATE_USER_ROLES,
DELETE_USER,
DELETE_OTHER_USER,
CHANGE_PASSWORD,
} = require('../../perms/constants');
@@ -93,7 +93,7 @@ const delUser = async (ctx, id) => {
updateBatchSize: 10000,
});
// Remove all actions against comments.
// Remove all actions against this users comments.
await transformSingleWithCursor(
Action.collection.find({ user_id: user.id, item_type: 'COMMENTS' }),
actionDecrTransformer,
@@ -112,34 +112,50 @@ const delUser = async (ctx, id) => {
.setOptions({ multi: true })
.remove();
// Removes all the user's reply counts on each of the comments that they
// have commented on.
// Remove the user from all other user's ignore lists.
await User.update(
{ ignoresUsers: user.id },
{
$pull: { ignoresUsers: user.id },
},
{ multi: true }
);
// For each comment that the user has authored, purge the comment data from it
// and unset their id from those comments.
await transformSingleWithCursor(
Comment.collection.aggregate([
{ $match: { author_id: user.id } },
{
$group: {
_id: '$parent_id',
count: { $sum: 1 },
},
},
]),
({ _id: parent_id, count }) => ({
query: { id: parent_id },
update: {
$inc: {
reply_count: -1 * count,
},
Comment.collection.find({ author_id: user.id }),
({
id,
asset_id,
status,
parent_id,
reply_count,
created_at,
updated_at,
}) => ({
query: { id },
replace: {
id,
body: null,
body_history: [],
asset_id,
author_id: null,
status_history: [],
status,
parent_id,
reply_count,
action_counts: {},
tags: [],
metadata: {},
deleted_at: new Date(),
created_at,
updated_at,
},
}),
Comment
);
// Remove all the user's comments.
await Comment.where({ author_id: user.id })
.setOptions({ multi: true })
.remove();
// Remove the user.
await user.remove();
};
@@ -225,7 +241,7 @@ module.exports = ctx => {
setUserSuspensionStatus(ctx, id, until, message);
}
if (ctx.user.can(DELETE_USER)) {
if (ctx.user.can(DELETE_OTHER_USER)) {
mutators.User.del = id => delUser(ctx, id);
}
+5 -1
View File
@@ -4,6 +4,7 @@ const {
SEARCH_ACTIONS,
SEARCH_COMMENT_STATUS_HISTORY,
VIEW_BODY_HISTORY,
VIEW_COMMENT_DELETED_AT,
} = require('../../perms/constants');
const {
decorateWithTags,
@@ -23,7 +24,9 @@ const Comment = {
return Comments.get.load(parent_id);
},
user({ author_id }, _, { loaders: { Users } }) {
return Users.getByID.load(author_id);
if (author_id) {
return Users.getByID.load(author_id);
}
},
replies({ id, asset_id, reply_count }, { query }, { loaders: { Comments } }) {
// Don't bother looking up replies if there aren't any there!
@@ -83,6 +86,7 @@ decorateWithTags(Comment);
decorateWithPermissionCheck(Comment, {
actions: [SEARCH_ACTIONS],
status_history: [SEARCH_COMMENT_STATUS_HISTORY],
deleted_at: [VIEW_COMMENT_DELETED_AT],
});
// Protect privileged fields.
+4 -1
View File
@@ -503,7 +503,7 @@ type Comment {
id: ID!
# The actual comment data.
body: String!
body: String
# The body history of the comment. Requires the `ADMIN` or `MODERATOR` role or
# the author.
@@ -537,6 +537,9 @@ type Comment {
# The status history of the comment. Requires the `ADMIN` or `MODERATOR` role.
status_history: [CommentStatusHistory!]
# The date that the comment was deleted at if it was.
deleted_at: Date
# The time when the comment was created
created_at: Date!
+2
View File
@@ -252,6 +252,7 @@ en:
CANNOT_IGNORE_STAFF: "Cannot ignore Staff members."
INCORRECT_PASSWORD: "Incorrect Password"
email: "Please enter a valid email."
DELETION_NOT_SCHEDULED: "Deletion was not scheduled"
confirm_password: "Passwords don't match. Please check again"
network_error: "Failed to connect to server. Check your internet connection and try again."
email_not_verified: "Email address {0} not verified."
@@ -272,6 +273,7 @@ en:
comment: comment
comment_is_ignored: "This comment is hidden because you ignored this user."
comment_is_rejected: "You have rejected this comment."
comment_is_deleted: "This comment was deleted."
comment_is_hidden: "This comment is not available."
comments: comments
configure_stream: "Configure"
+6 -2
View File
@@ -58,8 +58,6 @@ const Comment = new Schema(
},
body: {
type: String,
required: [true, 'The body is required.'],
minlength: 2,
},
body_history: [BodyHistoryItemSchema],
asset_id: String,
@@ -89,6 +87,12 @@ const Comment = new Schema(
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// deleted_at stores the date that the given comment was deleted.
deleted_at: {
type: Date,
default: null,
},
// Additional metadata stored on the field.
metadata: {
default: {},
+1 -1
View File
@@ -18,7 +18,7 @@
"lint:js": "eslint bin/cli* .",
"lint": "npm-run-all lint:*",
"plugins:reconcile": "./bin/cli plugins reconcile",
"test": "npm-run-all test:jest test:mocha",
"test": "npm-run-all test:jest test:server:mocha",
"test:jest": "NODE_ENV=test jest --runInBand",
"test:client": "NODE_ENV=test jest --projects client",
"test:server": "npm-run-all test:server:jest test:server:mocha",
+1 -1
View File
@@ -18,6 +18,6 @@ module.exports = {
UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS',
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
DELETE_USER: 'DELETE_USER',
DELETE_OTHER_USER: 'DELETE_OTHER_USER',
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
};
+1
View File
@@ -11,4 +11,5 @@ module.exports = {
VIEW_USER_ROLE: 'VIEW_USER_ROLE',
VIEW_USER_EMAIL: 'VIEW_USER_EMAIL',
VIEW_BODY_HISTORY: 'VIEW_BODY_HISTORY',
VIEW_COMMENT_DELETED_AT: 'VIEW_COMMENT_DELETED_AT',
};
+1
View File
@@ -56,6 +56,7 @@ module.exports = (user, perm) => {
case types.UPDATE_USER_ROLES:
case types.CREATE_TOKEN:
case types.REVOKE_TOKEN:
case types.DELETE_OTHER_USER:
return check(user, ['ADMIN']);
default:
+1
View File
@@ -14,6 +14,7 @@ module.exports = (user, perm) => {
case types.VIEW_USER_ROLE:
case types.VIEW_USER_EMAIL:
case types.VIEW_BODY_HISTORY:
case types.VIEW_COMMENT_DELETED_AT:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
+1
View File
@@ -8,4 +8,5 @@ export {
getErrorMessages,
getDefinitionName,
getShallowChanges,
createDefaultResponseFragments,
} from 'coral-framework/utils';
@@ -6,6 +6,7 @@ const { CREATE_MONGO_INDEXES } = require('../../../config');
const Comment = require('models/comment');
function getReactionConfig(reaction) {
// Ensure that the reaction is a lowercase string.
reaction = reaction.toLowerCase();
if (CREATE_MONGO_INDEXES) {
@@ -0,0 +1,44 @@
.container {
border: 1px solid #f26563;
border-radius: 2px;
color: #3b4a53;
padding: 20px 10px;
background-color: rgba(242, 101, 99, 0.1);
margin: 20px 0;
}
.button {
color: #787D80;
border-radius: 2px;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
&.secondary {
background-color: #787D80;
color: white;
}
}
.title {
margin: 0;
i.icon {
font-size: 1em;
padding: 4px;
}
}
.description {
margin: 0;
padding-left: 22px;
padding-bottom: 15px;
}
.actions {
text-align: center;
}
@@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { t } from 'plugin-api/beta/client/services';
import moment from 'moment';
import { Button, Icon } from 'plugin-api/beta/client/components/ui';
import styles from './AccountDeletionRequestedSign.css';
import { getErrorMessages } from 'coral-framework/utils';
import { scheduledDeletionDelayHours } from '../../config';
class AccountDeletionRequestedSign extends React.Component {
cancelAccountDeletion = async () => {
const { cancelAccountDeletion, notify } = this.props;
try {
await cancelAccountDeletion();
notify('success', t('delete_request.account_deletion_cancelled'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
render() {
const { me: { scheduledDeletionDate } } = this.props.root;
const deletionScheduledFor = moment(scheduledDeletionDate).format(
'MMM Do YYYY, h:mm a'
);
const deletionScheduledOn = moment(scheduledDeletionDate)
.subtract(scheduledDeletionDelayHours, 'hours')
.format('MMM Do YYYY, h:mm a');
return (
<div className={styles.container}>
<h4 className={styles.title}>
<Icon name="warning" className={styles.icon} />{' '}
{t('delete_request.account_deletion_requested')}
</h4>
<p className={styles.description}>
{t('delete_request.received_on')}
{deletionScheduledFor}.
</p>
<p className={styles.description}>
{t('delete_request.cancel_request_description')}
<b>
{' '}
{t('delete_request.before')} {deletionScheduledOn}
</b>.
</p>
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.secondary)}
onClick={this.cancelAccountDeletion}
>
{t('delete_request.cancel_account_deletion_request')}
</Button>
</div>
</div>
);
}
}
AccountDeletionRequestedSign.propTypes = {
cancelAccountDeletion: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
export default AccountDeletionRequestedSign;
@@ -0,0 +1,23 @@
.button {
color: #787D80;
border-radius: 2px;
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
&.secondary {
background-color: #787D80;
color: white;
}
> i {
font-size: 1.2em;
vertical-align: middle;
}
}
@@ -0,0 +1,118 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import moment from 'moment';
import styles from './DeleteMyAccount.css';
import { Button } from 'plugin-api/beta/client/components/ui';
import DeleteMyAccountDialog from './DeleteMyAccountDialog';
import { getErrorMessages } from 'coral-framework/utils';
import { t } from 'plugin-api/beta/client/services';
const initialState = { showDialog: false };
class DeleteMyAccount extends React.Component {
state = initialState;
showDialog = () => {
this.setState({
showDialog: true,
});
};
closeDialog = () => {
this.setState({
showDialog: false,
});
};
cancelAccountDeletion = async () => {
const { cancelAccountDeletion, notify } = this.props;
try {
await cancelAccountDeletion();
notify('success', t('delete_request.account_deletion_requested'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
requestAccountDeletion = async () => {
const { requestAccountDeletion, notify } = this.props;
try {
await requestAccountDeletion();
notify('success', t('delete_request.account_deletion_requested'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
render() {
const {
me: { scheduledDeletionDate },
settings: { organizationContactEmail },
} = this.props.root;
return (
<div className="talk-plugin-auth--delete-my-account">
<DeleteMyAccountDialog
requestAccountDeletion={this.requestAccountDeletion}
showDialog={this.state.showDialog}
closeDialog={this.closeDialog}
scheduledDeletionDate={scheduledDeletionDate}
organizationContactEmail={organizationContactEmail}
/>
<h3
className={cn(
styles.title,
'talk-plugin-auth--delete-my-account-description'
)}
>
{t('delete_request.delete_my_account')}
</h3>
<p
className={cn(
styles.description,
'talk-plugin-auth--delete-my-account-description'
)}
>
{t('delete_request.delete_my_account_description')}
</p>
<p
className={cn(
styles.description,
'talk-plugin-auth--delete-my-account-description'
)}
>
{scheduledDeletionDate &&
t(
'delete_request.already_submitted_request_description',
moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a')
)}
</p>
{scheduledDeletionDate ? (
<Button
className={cn(styles.button, styles.secondary)}
onClick={this.cancelAccountDeletion}
>
{t('delete_request.cancel_account_deletion_request')}
</Button>
) : (
<Button
className={cn(styles.button)}
icon="delete"
onClick={this.showDialog}
>
{t('delete_request.delete_my_account')}
</Button>
)}
</div>
);
}
}
DeleteMyAccount.propTypes = {
requestAccountDeletion: PropTypes.func.isRequired,
cancelAccountDeletion: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
export default DeleteMyAccount;
@@ -0,0 +1,38 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 380px;
top: 10px;
font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
font-size: 14px;
border-radius: 4px;
padding: 20px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
&:hover {
color: #6b6b6b;
}
}
.title {
font-size: 1.2em;
margin-top: 0;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
}
@@ -0,0 +1,104 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './DeleteMyAccountDialog.css';
import { Dialog } from 'plugin-api/beta/client/components/ui';
import StepProgress from './StepProgress';
import DeleteMyAccountStep0 from './DeleteMyAccountStep0';
import DeleteMyAccountStep1 from './DeleteMyAccountStep1';
import DeleteMyAccountStep2 from './DeleteMyAccountStep2';
import DeleteMyAccountStep3 from './DeleteMyAccountStep3';
import DeleteMyAccountFinalStep from './DeleteMyAccountFinalStep';
import { t } from 'plugin-api/beta/client/services';
const initialState = { step: 0, formData: {} };
class DeleteMyAccountDialog extends React.Component {
state = initialState;
goToNextStep = () => {
this.setState(state => ({
step: state.step + 1,
}));
};
clear = () => {
this.setState(initialState);
};
cancel = () => {
this.clear();
this.props.closeDialog();
};
onChange = e => {
const { name, value } = e.target;
this.setState(state => ({
formData: {
...state.formData,
[name]: value,
},
}));
};
render() {
const { step } = this.state;
const { scheduledDeletionDate, organizationContactEmail } = this.props;
return (
<Dialog open={this.props.showDialog} className={styles.dialog}>
<span className={styles.close} onClick={this.cancel}>
×
</span>
<h3 className={styles.title}>
{t('delete_request.delete_my_account')}
</h3>
<StepProgress currentStep={this.state.step} totalSteps={4} />
{step === 0 && (
<DeleteMyAccountStep0
goToNextStep={this.goToNextStep}
cancel={this.cancel}
/>
)}
{step === 1 && (
<DeleteMyAccountStep1
goToNextStep={this.goToNextStep}
cancel={this.cancel}
/>
)}
{step === 2 && (
<DeleteMyAccountStep2
goToNextStep={this.goToNextStep}
cancel={this.cancel}
/>
)}
{step === 3 && (
<DeleteMyAccountStep3
formData={this.state.formData}
goToNextStep={this.goToNextStep}
cancel={this.cancel}
requestAccountDeletion={this.props.requestAccountDeletion}
onChange={this.onChange}
/>
)}
{step === 4 && (
<DeleteMyAccountFinalStep
scheduledDeletionDate={scheduledDeletionDate}
organizationContactEmail={organizationContactEmail}
finish={this.cancel}
/>
)}
</Dialog>
);
}
}
DeleteMyAccountDialog.propTypes = {
showDialog: PropTypes.bool.isRequired,
closeDialog: PropTypes.func.isRequired,
requestAccountDeletion: PropTypes.func.isRequired,
scheduledDeletionDate: PropTypes.any.isRequired,
organizationContactEmail: PropTypes.string.isRequired,
};
export default DeleteMyAccountDialog;
@@ -0,0 +1,60 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import { Button, Icon } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
import moment from 'moment';
const DeleteMyAccountFinalStep = props => (
<div className={styles.step}>
<p className={styles.description}>
{t('delete_request.your_request_submitted_description')}
</p>
<div className={cn(styles.box, styles.scheduledDeletion)}>
<strong className={styles.block}>
{t('delete_request.your_account_deletion_scheduled')}
</strong>
<strong className={styles.block}>
<Icon name="access_time" className={styles.timeIcon} />
<span>
{moment(props.scheduledDeletionDate).format('MMM Do YYYY, h:mm a')}
</span>
</strong>
</div>
<p className={styles.description}>
<strong> {t('delete_request.changed_your_mind')}</strong>{' '}
{t('delete_request.simply_go_to')} <strong>
{t('delete_request.cancel_account_deletion_request')}.
</strong>
</p>
<p className={styles.description}>
<strong>{t('delete_request.tell_us_why')}.</strong>{' '}
{t('delete_request.feedback_copy')}{' '}
<a href={`mailto:${props.organizationContactEmail}`}>
{props.organizationContactEmail}
</a>.
</p>
<div className={cn(styles.actions, styles.columnView)}>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.finish}
full
>
{t('delete_request.done')}
</Button>
</div>
</div>
);
DeleteMyAccountFinalStep.propTypes = {
finish: PropTypes.func.isRequired,
scheduledDeletionDate: PropTypes.any.isRequired,
organizationContactEmail: PropTypes.string.isRequired,
};
export default DeleteMyAccountFinalStep;
@@ -0,0 +1,116 @@
.list {
padding: 0;
margin: 20px 0;
list-style: none;
}
.item {
display: flex;
margin-bottom: 20px;
.itemIcon {
flex-grow: 0;
}
.text {
flex-grow: 1;
padding-left: 10px;
}
> i.itemIcon {
font-size: 1.3em;
}
}
.button {
color: #787D80;
border-radius: 2px;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
&.cancel {
background-color: transparent;
color: #787D80;
}
&.proceed {
background-color: #3498DB;
color: white;
}
&.danger {
background-color: #FA4643;
color: white;
}
}
.actions {
text-align: right;
padding-top: 20px;
&.columnView {
display: flex;
flex-direction: column;
align-items: center;
}
}
.title {
font-size: 1.2em;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
margin-bottom: 15px;
}
.box {
display: flex;
flex-direction: column;
margin-bottom: 15px;
}
.note {
margin: 10px 0;
}
.subTitle {
font-size: 1em;
margin-bottom: 8px;
}
.textBox {
background-color: #F1F2F2;
border: none;
width: 100%;
padding: 15px;
box-sizing: border-box;
color: #3B4A53;
font-size: 1em;
margin-bottom: 15px;
}
.block {
display: block;
margin-top: 2px;
}
.step {
padding-top: 20px;
}
.scheduledDeletion {
i.timeIcon {
font-size: 1.2em;
padding: 4px;
}
}
@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button, Icon } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
const DeleteMyAccountStep0 = props => (
<div className={styles.step}>
<p className={styles.description}>
{t('delete_request.step_0.you_are_attempting')}
</p>
<ul className={styles.list}>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>{t('delete_request.step_0.item_1')}</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>{t('delete_request.step_0.item_2')}</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>{t('delete_request.step_0.item_3')}</span>
</li>
</ul>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.goToNextStep}
>
{t('delete_request.proceed')}
</Button>
</div>
</div>
);
DeleteMyAccountStep0.propTypes = {
goToNextStep: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
};
export default DeleteMyAccountStep0;
@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
import { scheduledDeletionDelayHours } from '../../config';
const DeleteMyAccountStep1 = props => (
<div className={styles.step}>
<h4 className={styles.subTitle}>{t('delete_request.step_1.subtitle')}</h4>
<p className={styles.description}>
{t('delete_request.step_1.description', scheduledDeletionDelayHours)}
</p>
<h4 className={styles.subTitle}>{t('delete_request.step_1.subtitle_2')}</h4>
<p className={styles.description}>
{t('delete_request.step_1.description_2', scheduledDeletionDelayHours)}
</p>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.goToNextStep}
>
{t('delete_request.proceed')}
</Button>
</div>
</div>
);
DeleteMyAccountStep1.propTypes = {
goToNextStep: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
};
export default DeleteMyAccountStep1;
@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
const DeleteMyAccountStep2 = props => (
<div className={styles.step}>
<p className={styles.description}>
{t('delete_request.step_2.description')}
</p>
<p className={styles.description}>
{t('delete_request.step_2.to_download')}
<strong className={styles.block}>
{t('delete_request.step_2.path')}
</strong>
</p>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.goToNextStep}
>
{t('delete_request.proceed')}
</Button>
</div>
</div>
);
DeleteMyAccountStep2.propTypes = {
goToNextStep: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
};
export default DeleteMyAccountStep2;
@@ -0,0 +1,96 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import InputField from './InputField';
import { t } from 'plugin-api/beta/client/services';
const initialState = {
showError: false,
};
class DeleteMyAccountStep3 extends React.Component {
state = initialState;
phrase = 'delete';
showError = () => {
this.setState({
showError: true,
});
};
clear = () => {
this.setState(initialState);
};
deleteAndContinue = async () => {
if (this.formHasError()) {
this.showError();
return;
}
await this.props.requestAccountDeletion();
this.clear();
this.props.goToNextStep();
};
formHasError = () =>
!this.props.formData.confirmPhrase ||
this.props.formData.confirmPhrase !== this.phrase;
render() {
return (
<div className={styles.step}>
<h4 className={styles.subTitle}>
{t('delete_request.step_3.subtitle')}
</h4>
<p className={styles.description}>
{t('delete_request.step_3.description')}
</p>
<input
className={styles.textBox}
disabled={true}
readOnly={true}
value={this.phrase}
/>
<InputField
id="confirmPhrase"
label={t('delete_request.step_3.type_to_confirm')}
name="confirmPhrase"
type="text"
onChange={this.props.onChange}
defaultValue=""
hasError={this.formHasError()}
errorMsg={t('delete_request.input_is_not_correct')}
showError={this.state.showError}
columnDisplay
/>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={this.props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.danger)}
onClick={this.deleteAndContinue}
>
{t('delete_request.delete_my_account')}
</Button>
</div>
</div>
);
}
}
DeleteMyAccountStep3.propTypes = {
goToNextStep: PropTypes.func.isRequired,
requestAccountDeletion: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
formData: PropTypes.object.isRequired,
};
export default DeleteMyAccountStep3;
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import { t } from 'plugin-api/beta/client/services';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DownloadCommentHistory.css';
import { getErrorMessages } from 'coral-framework/utils';
import { downloadRateLimitDays } from '../../config';
export const readableDuration = durAsHours => {
const durAsDays = Math.ceil(durAsHours / 24);
@@ -19,21 +21,30 @@ export const readableDuration = durAsHours => {
class DownloadCommentHistory extends Component {
static propTypes = {
requestDownloadLink: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
requestDownloadLink = async () => {
const { requestDownloadLink, notify } = this.props;
try {
await requestDownloadLink();
notify('success', t('download_request.download_preparing'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
render() {
const {
root: { me: { lastAccountDownload } },
requestDownloadLink,
} = this.props;
const { root: { me: { lastAccountDownload } } } = this.props;
const now = new Date();
const lastAccountDownloadDate =
lastAccountDownload && new Date(lastAccountDownload);
const hoursLeft = lastAccountDownloadDate
? Math.ceil(
7 * 24 - (now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6
downloadRateLimitDays * 24 -
(now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6
)
: 0;
const canRequestDownload = !lastAccountDownloadDate || hoursLeft <= 0;
@@ -43,7 +54,7 @@ class DownloadCommentHistory extends Component {
<h3>{t('download_request.section_title')}</h3>
<p>
{t('download_request.you_will_get_a_copy')}{' '}
<b>{t('download_request.download_rate')}</b>.
<b>{t('download_request.download_rate', downloadRateLimitDays)}</b>.
</p>
{lastAccountDownloadDate && (
<p className={styles.most_recent}>
@@ -52,7 +63,7 @@ class DownloadCommentHistory extends Component {
</p>
)}
{canRequestDownload ? (
<Button className={styles.button} onClick={requestDownloadLink}>
<Button className={styles.button} onClick={this.requestDownloadLink}>
<i className="material-icons" aria-hidden={true}>
file_download
</i>{' '}
@@ -0,0 +1,12 @@
.errorMsg {
color: #FA4643;
font-size: 0.9em;
i.warningIcon {
font-size: 17px;
}
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ErrorMessage.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
const ErrorMessage = ({ children }) => (
<div className={styles.errorMsg}>
<Icon className={styles.warningIcon} name="warning" />
<span>{children}</span>
</div>
);
ErrorMessage.propTypes = {
children: PropTypes.node,
};
export default ErrorMessage;
@@ -0,0 +1,85 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
flex-direction: column;
}
.columnDisplay {
flex-direction: column;
.detailItemMessage {
padding: 4px 0 0;
}
}
.detailItemContent {
display: flex;
}
.detailInput {
border: solid 1px #787D80;
border-radius: 2px;
background-color: white;
height: 30px;
display: inline-block;
width: 230px;
display: flex;
box-sizing: border-box;
> .detailIcon {
font-size: 1.2em;
padding: 0 5px;
color: #787D80;
line-height: 30px;
}
&.error {
border: solid 2px #FA4643;
}
&.disabled {
background-color: #E0E0E0;
}
}
.detailLabel {
color: #4C4C4D;
font-size: 1em;
display: block;
margin-bottom: 4px;
}
.detailValue {
background: transparent;
border: none;
font-size: 1em;
color: #000;
outline: none;
flex: 1;
height: 100%;
box-sizing: border-box;
padding: 0 6px;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 6px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './InputField.css';
import ErrorMessage from './ErrorMessage';
import { Icon } from 'plugin-api/beta/client/components/ui';
const InputField = ({
id = '',
label = '',
type = 'text',
name = '',
onChange = () => {},
showError = true,
hasError = false,
errorMsg = '',
children,
columnDisplay = false,
showSuccess = false,
validationType = '',
icon = '',
value = '',
defaultValue = '',
disabled = false,
}) => {
const inputValue = {
...(value ? { value } : {}),
...(defaultValue ? { defaultValue } : {}),
};
return (
<div className={styles.detailItem}>
<div className={cn(styles.detailItemContainer)}>
{label && (
<label className={styles.detailLabel} id={id}>
{label}
</label>
)}
<div
className={cn(styles.detailItemContent, {
[styles.columnDisplay]: columnDisplay,
})}
>
<div
className={cn(
styles.detailInput,
{ [styles.error]: hasError && showError },
{ [styles.disabled]: disabled }
)}
>
{icon && <Icon name={icon} className={styles.detailIcon} />}
<input
id={id}
type={type}
name={name}
className={styles.detailValue}
onChange={onChange}
autoComplete="off"
data-validation-type={validationType}
disabled={disabled}
{...inputValue}
/>
</div>
<div className={styles.detailItemMessage}>
{!hasError &&
showSuccess &&
value && (
<Icon className={styles.checkIcon} name="check_circle" />
)}
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
</div>
</div>
</div>
{children}
</div>
);
};
InputField.propTypes = {
id: PropTypes.string,
disabled: PropTypes.bool,
label: PropTypes.string,
type: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string,
defaultValue: PropTypes.string,
icon: PropTypes.string,
showError: PropTypes.bool,
hasError: PropTypes.bool,
errorMsg: PropTypes.string,
children: PropTypes.node,
columnDisplay: PropTypes.bool,
showSuccess: PropTypes.bool,
validationType: PropTypes.string,
};
export default InputField;
@@ -0,0 +1,36 @@
.step {
color: #BBBEBF;
background-color: white;
padding: 6px;
z-index: 10;
> .icon {
font-size: 25px;
}
&.current {
color: rgba(0, 205, 115, 0.3);
}
&.completed {
color: #00CD73;
}
}
.line {
position: absolute;
border: solid 2px #BBBEBF;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 0;
}
.container {
display: flex;
position: relative;
justify-content: space-between;
align-items: center;
height: 50px;
margin: 0 20px;
}
@@ -0,0 +1,48 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './StepProgress.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
const CheckItem = ({ current = false, completed = false }) => (
<span
className={cn(styles.step, {
[styles.current]: current,
[styles.completed]: completed,
})}
>
<Icon name="check_circle" className={styles.icon} />
</span>
);
CheckItem.propTypes = {
current: PropTypes.bool.isRequired,
completed: PropTypes.bool.isRequired,
};
const Line = () => <hr className={styles.line} />;
class StepProgress extends React.Component {
render() {
const { currentStep, totalSteps } = this.props;
return (
<div className={styles.container}>
{Array.from({ length: totalSteps }).map((_, i) => (
<CheckItem
key={`step_${i}`}
completed={i < currentStep}
current={currentStep === i}
/>
))}
<Line />
</div>
);
}
}
StepProgress.propTypes = {
currentStep: PropTypes.number.isRequired,
totalSteps: PropTypes.number.isRequired,
};
export default StepProgress;
@@ -0,0 +1,25 @@
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect, withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import AccountDeletionRequestedSign from '../components/AccountDeletionRequestedSign';
import { notify } from 'coral-framework/actions/notification';
import { withCancelAccountDeletion } from '../hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const withData = withFragments({
root: gql`
fragment Talk_AccountDeletionRequestedSignIn_root on RootQuery {
me {
scheduledDeletionDate
}
}
`,
});
export default compose(
connect(null, mapDispatchToProps),
withCancelAccountDeletion,
withData,
excludeIf(({ root: { me } }) => !me || !me.scheduledDeletionDate)
)(AccountDeletionRequestedSign);
@@ -0,0 +1,28 @@
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect, withFragments } from 'plugin-api/beta/client/hocs';
import DeleteMyAccount from '../components/DeleteMyAccount';
import { notify } from 'coral-framework/actions/notification';
import { withRequestAccountDeletion, withCancelAccountDeletion } from '../hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const withData = withFragments({
root: gql`
fragment Talk_DeleteMyAccount_root on RootQuery {
me {
scheduledDeletionDate
}
settings {
organizationContactEmail
}
}
`,
});
export default compose(
connect(null, mapDispatchToProps),
withRequestAccountDeletion,
withCancelAccountDeletion,
withData
)(DeleteMyAccount);
@@ -1,27 +1,36 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
import DownloadCommentHistory from '../components/DownloadCommentHistory';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { withRequestDownloadLink } from '../hocs';
import { connect, withFragments } from 'plugin-api/beta/client/hocs';
import { withRequestDownloadLink } from '../hocs';
import { notify } from 'coral-framework/actions/notification';
class DownloadCommentHistoryContainer extends Component {
static propTypes = {
requestDownloadLink: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
notify: PropTypes.func.isRequired,
};
render() {
return (
<DownloadCommentHistory
root={this.props.root}
notify={this.props.notify}
requestDownloadLink={this.props.requestDownloadLink}
/>
);
}
}
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const enhance = compose(
connect(null, mapDispatchToProps),
withFragments({
root: gql`
fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery {
@@ -1,8 +1,16 @@
import update from 'immutability-helper';
import { createDefaultResponseFragments } from 'coral-framework/utils';
export default {
fragments: {
...createDefaultResponseFragments(
'RequestAccountDeletionResponse',
'RequestDownloadLinkResponse',
'CancelAccountDeletionResponse'
),
},
mutations: {
DownloadCommentHistory: () => ({
RequestDownloadLink: () => ({
updateQueries: {
CoralEmbedStream_Profile: previousData =>
update(previousData, {
@@ -1,10 +1,13 @@
import DownloadCommentHistory from './containers/DownloadCommentHistory';
import DeleteMyAccount from './containers/DeleteMyAccount';
import AccountDeletionRequestedSign from './containers/AccountDeletionRequestedSign';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
profileSettings: [DownloadCommentHistory],
stream: [AccountDeletionRequestedSign],
profileSettings: [DownloadCommentHistory, DeleteMyAccount],
},
translations,
...graphql,
@@ -2,7 +2,7 @@ en:
download_request:
section_title: "Download My Comment History"
you_will_get_a_copy: "You will recieve an email with a link to download your comment history. You can make"
download_rate: "one download request every 7 days"
download_rate: "one download request every {0} days"
most_recent_request: "Your most recent request"
request: "Request Comment History"
rate_limit: "You can submit another Comment History request in {0}"
@@ -10,3 +10,42 @@ en:
days: "{0} days"
hour: "{0} hour"
day: "{0} day"
download_preparing: "Account Download Preparing - Check your email shortly for a download link"
delete_request:
account_deletion_cancelled: 'Account Deletion Request Cancelled - Your request to delete your account has been cancelled.'
account_deletion_requested: 'Account Deletion Requested'
received_on: "A request to delete your account was received on "
cancel_request_description: "If you would like to reactivate your account, you may cancel your request to delete your account below"
before: "before"
cancel_account_deletion_request: "Cancel Account Deletion Request"
delete_my_account: "Delete My Account"
delete_my_account_description: "Deleting your account will permanently erase your profile and remove all your comments from this site."
already_submitted_request_description: "You have already submitted a request to delete your account. Your account will be deleted on {0}. You may cancel the request until that time"
your_request_submitted_description: "Your request has been submitted and confirmation has been sent to the email address associated with your account."
your_account_deletion_scheduled: "Your account is scheduled to be deleted at:"
changed_your_mind: "Changed your mind?"
simply_go_to: "Simply go to your account again before this time and click"
tell_us_why: "Tell us why"
feedback_copy: "We would like to know why you chose to delete your account. Send us feedback on our comment system by emailing"
done: "Done"
cancel: "Cancel"
proceed: "Proceed"
input_is_not_correct: "The input is not correct"
step_0:
you_are_attempting: "You are attempting to delete your account. This means:"
item_1: "All of your comments are removed from this site"
item_2: "All of your comments are deleted from our database"
item_3: "Your username and email address are removed from our system"
step_1:
subtitle: "When will my account be deleted?"
description: "Your account will be deleted {0} hours after your request has been submitted."
subtitle_2: "Can I still write comments until my account is deleted?"
description_2: "Yes, you can still comment, reply, and react to comments until the {0} hours expires."
step_2:
description: "Before your account is deleted, we recommend you download your comment history for your records. After your account is deleted, you will be unable to request your comment history."
to_download: "To download your comment history go to:"
path: "My Profile > Download My Comment History"
step_3:
subtitle: "Are you sure you want to delete your account?"
description: "To confirm you would like to delete your account please type in the following phrase into the text box below:"
type_to_confirm: "Type phrase below to confirm"
@@ -0,0 +1,4 @@
module.exports = {
scheduledDeletionDelayHours: 24,
downloadRateLimitDays: 7,
};
@@ -7,6 +7,7 @@
"private": false,
"dependencies": {
"archiver": "^2.1.1",
"cron": "^1.3.0",
"csv-stringify": "^3.0.0"
}
}
@@ -1,7 +1,15 @@
const path = require('path');
const moment = require('moment');
const { CronJob } = require('cron');
const { get } = require('lodash');
const { ErrMissingEmail } = require('errors');
module.exports = connectors => {
const { services: { Mailer } } = connectors;
const {
services: { Mailer, I18n },
models: { User },
graph: { Context },
} = connectors;
// Setup the mail templates.
['txt', 'html'].forEach(format => {
@@ -11,4 +19,128 @@ module.exports = connectors => {
format
);
});
// Setup the cron job that will scan for accounts to delete every 30 minutes.
new CronJob({
cronTime: '0,30 * * * *',
timeZone: 'America/New_York',
start: true,
runOnInit: true,
onTick: async () => {
// Create the context we'll use to perform user deletions.
const ctx = Context.forSystem();
try {
// Grab some settings.
const { loaders: { Settings } } = ctx;
const {
organizationName,
organizationContactEmail,
} = await Settings.load([
'organizationName',
'organizationContactEmail',
]);
// rescheduledDeletionDate is the date in the future that we'll set the
// user's account to be deleted on if this delete fails.
const rescheduledDeletionDate = moment()
.add(1, 'hours')
.toDate();
// Keep running for each user we can pull.
while (true) {
// We'll find any user that has an account deletion date before now
// and update the user such that their deletion time is 1 hour from
// now. This will ensure that only one instance can pull the same
// user at a time, and if the delete fails, it will be retried an
// hour from now. If the deletion was successful, well, it can't be
// retried because the reference to the scheduledDeletionDate will
// get deleted along with the user.
const user = await User.findOneAndUpdate(
{
'metadata.scheduledDeletionDate': { $lte: new Date() },
},
{
$set: {
'metadata.scheduledDeletionDate': rescheduledDeletionDate,
},
}
);
if (!user) {
// There are no more users that meet the search criteria! We're
// done!
ctx.log.info('no more users are scheduled for deletion');
break;
}
// Get the user's email address.
const reply = await ctx.graphql(
`
query GetUserEmailAddress($user_id: ID!) {
user(id: $user_id) {
email
}
}
`,
{ user_id: user.id }
);
if (reply.errors) {
throw reply.errors;
}
const email = get(reply, 'data.user.email');
if (!email) {
throw new ErrMissingEmail();
}
ctx.log.info(
{
userID: user.id,
scheduledDeletionDate: user.metadata.scheduledDeletionDate,
},
'starting user delete'
);
// Delete the user using the existing graph call.
const { data, errors } = await ctx.graphql(
`
mutation DeleteUser($user_id: ID!) {
delUser(id: $user_id) {
errors {
translation_key
}
}
}
`,
{ user_id: user.id }
);
if (errors) {
throw errors;
}
if (data.errors) {
throw data.errors;
}
ctx.log.info({ userID: user.id }, 'user was deleted successfully');
// Send the download link via the user's attached email account.
await Mailer.send({
template: 'plain',
locals: {
body: I18n.t(
'email.deleted.body',
organizationName,
organizationContactEmail
),
},
subject: I18n.t('email.deleted.subject', organizationName),
email,
});
}
} catch (err) {
ctx.log.error({ err }, 'could not handle user deletions');
}
},
});
};
@@ -15,4 +15,29 @@ class ErrDownloadToken extends TalkError {
}
}
module.exports = { ErrDownloadToken };
// ErrDeletionAlreadyScheduled is returned when a user requests that their
// account get deleted when their account is already scheduled for deletion.
class ErrDeletionAlreadyScheduled extends TalkError {
constructor() {
super('Deletion is already scheduled', {
translation_key: 'DELETION_ALREADY_SCHEDULED',
status: 400,
});
}
}
// ErrDeletionNotScheduled is returned when a user requests that their
// account deletion to be canceled when it was not scheduled for deletion.
class ErrDeletionNotScheduled extends TalkError {
constructor() {
super('Deletion was not scheduled', {
translation_key: 'DELETION_NOT_SCHEDULED',
status: 400,
});
}
}
module.exports = {
ErrDownloadToken,
ErrDeletionAlreadyScheduled,
ErrDeletionNotScheduled,
};
@@ -1,8 +1,17 @@
const { get } = require('lodash');
const moment = require('moment');
const uuid = require('uuid/v4');
const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
const {
ErrDeletionAlreadyScheduled,
ErrDeletionNotScheduled,
} = require('./errors');
const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors');
const { URL } = require('url');
const {
scheduledDeletionDelayHours,
downloadRateLimitDays,
} = require('../config');
// generateDownloadLinks will generate a signed set of links for a given user to
// download an archive of their data.
@@ -37,21 +46,26 @@ async function sendDownloadLink(ctx) {
} = ctx;
// downloadLinkLimiter can be used to limit downloads for the user's data to
// once every 7 days.
const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
// once every ${downloadRateLimitDays} days.
const downloadLinkLimiter = new Limit(
'profileDataDownloadLimiter',
1,
`${downloadRateLimitDays}d`
);
// Check that the user has not already requested a download within the last
// 7 days.
// ${downloadRateLimitDays} days.
const attempts = await downloadLinkLimiter.get(user.id);
if (attempts && attempts >= 1) {
throw new ErrMaxRateLimit();
}
// Check if the lastAccountDownload time is within 7 days.
// Check if the lastAccountDownload time is within ${downloadRateLimitDays}
// days.
if (
user.lastAccountDownload &&
moment(user.lastAccountDownload)
.add(7, 'days')
.add(downloadRateLimitDays, 'days')
.isAfter(moment())
) {
throw new ErrMaxRateLimit();
@@ -87,20 +101,118 @@ async function sendDownloadLink(ctx) {
);
}
// requestDeletion will schedule the current user to have their account deleted
// by setting the `scheduledDeletionDate` on the user
// ${scheduledDeletionDelayHours} hours from now.
async function requestDeletion({
user,
loaders: { Settings },
connectors: { models: { User }, services: { Users, I18n } },
}) {
// Ensure the user doesn't already have a deletion scheduled.
if (get(user, 'metadata.scheduledDeletionDate')) {
throw new ErrDeletionAlreadyScheduled();
}
// Get the date in the future ${scheduledDeletionDelayHours} hours from now.
const scheduledDeletionDate = moment().add(
scheduledDeletionDelayHours,
'hours'
);
// Amend the scheduledDeletionDate on the user.
await User.update(
{ id: user.id },
{
$set: {
'metadata.scheduledDeletionDate': scheduledDeletionDate.toDate(),
},
}
);
const { organizationName } = await Settings.load('organizationName');
// Send the download link via the user's attached email account.
await Users.sendEmail(user, {
template: 'plain',
locals: {
body: I18n.t(
'email.delete.body',
organizationName,
scheduledDeletionDate.format('MMM Do YYYY, h:mm:ss a')
),
},
subject: I18n.t('email.delete.subject', organizationName),
});
return scheduledDeletionDate.toDate();
}
// cancelDeletion will unset the scheduled deletion date on the user account
// that is used to indicate that the user was scheduled for deletion.
async function cancelDeletion({
user,
loaders: { Settings },
connectors: { models: { User }, services: { I18n, Users } },
}) {
// Ensure the user has a deletion scheduled.
const scheduledDeletionDate = get(
user,
'metadata.scheduledDeletionDate',
null
);
if (!scheduledDeletionDate) {
throw new ErrDeletionNotScheduled();
}
// Amend the scheduledDeletionDate on the user.
await User.update(
{ id: user.id },
{ $unset: { 'metadata.scheduledDeletionDate': 1 } }
);
const { organizationName } = await Settings.load('organizationName');
// Send the download link via the user's attached email account.
await Users.sendEmail(user, {
template: 'plain',
locals: {
body: I18n.t(
'email.cancelDelete.body',
organizationName,
moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a')
),
},
subject: I18n.t('email.cancelDelete.subject', organizationName),
});
}
// downloadUser will return the download file url that can be used to directly
// download the archive.
async function downloadUser(ctx, userID) {
if (ctx.user.role !== 'ADMIN') {
throw new ErrNotAuthorized();
}
const { downloadFileURL } = await generateDownloadLinks(ctx, userID);
return downloadFileURL;
}
module.exports = ctx => ({
User: {
requestDownloadLink: () => sendDownloadLink(ctx),
download:
// Only ADMIN users can execute an account download.
ctx.user && ctx.user.role === 'ADMIN'
? userID => downloadUser(ctx, userID)
: () => Promise.reject(new ErrNotAuthorized()),
},
});
module.exports = ctx =>
ctx.user
? {
User: {
requestDownloadLink: () => sendDownloadLink(ctx),
requestDeletion: () => requestDeletion(ctx),
cancelDeletion: () => cancelDeletion(ctx),
download: userID => downloadUser(ctx, userID),
},
}
: {
User: {
requestDownloadLink: () => Promise.reject(new ErrNotAuthorized()),
requestDeletion: () => Promise.reject(new ErrNotAuthorized()),
cancelDeletion: () => Promise.reject(new ErrNotAuthorized()),
download: () => Promise.reject(new ErrNotAuthorized()),
},
};
@@ -5,6 +5,12 @@ module.exports = {
requestDownloadLink: async (_, args, { mutators: { User } }) => {
await User.requestDownloadLink();
},
requestAccountDeletion: async (_, args, { mutators: { User } }) => ({
scheduledDeletionDate: await User.requestDeletion(),
}),
cancelAccountDeletion: async (_, args, { mutators: { User } }) => {
await User.cancelDeletion();
},
downloadUser: async (_, { id }, { mutators: { User } }) => ({
archiveURL: await User.download(id),
}),
@@ -19,5 +25,17 @@ module.exports = {
return get(user, 'metadata.lastAccountDownload', null);
},
scheduledDeletionDate: (user, args, { user: currentUser }) => {
// If the current user is not the requesting user, and the user is not
// an admin or a moderator, return nothing.
if (
user.id !== currentUser.id &&
!['ADMIN', 'MODERATOR'].includes(user.role)
) {
return null;
}
return get(user, 'metadata.scheduledDeletionDate', null);
},
},
};
@@ -3,14 +3,46 @@ type User {
# lastAccountDownload is the date that the user last requested a comment
# download.
lastAccountDownload: Date
# scheduledDeletionDate is the data for which the user account will be deleted
# after. The account may be deleted up to half an hour after this date because
# the job responsible for deleting the scheduled account will only run once
# every half hour.
scheduledDeletionDate: Date
}
# RequestDownloadLinkResponse contains the account download errors relating to
# the request for an account download.
type RequestDownloadLinkResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# RequestAccountDeletionResponse contains the account deletion schedule errors
# relating to schedulding an account for deletion.
type RequestAccountDeletionResponse implements Response {
# scheduledDeletionDate is the data for which the user account will be deleted
# after. The account may be deleted up to half an hour after this date because
# the job responsible for deleting the scheduled account will only run once
# every half hour.
scheduledDeletionDate: Date
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# CancelAccountDeletionResponse contains the account deletion errors relating to
# canceling an account deletion that was scheduled.
type CancelAccountDeletionResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# DownloadUserResponse contaisn the account download archiveURL that can be used
# to directly download a zip file containing the user data.
type DownloadUserResponse implements Response {
# archiveURL is the link that can be used within the next 1 hour to download a
@@ -27,6 +59,14 @@ type RootMutation {
# users email address.
requestDownloadLink: RequestDownloadLinkResponse
# requestAccountDeletion requests that the current account get deleted. The
# mutation will return the date that the account is scheduled to be deleted.
requestAccountDeletion: RequestAccountDeletionResponse
# cancelAccountDeletion will cancel a pending account deletion that was
# previously scheduled.
cancelAccountDeletion: CancelAccountDeletionResponse
# downloadUser will provide an account download for the indicated User. This
# mutation requires the ADMIN role.
downloadUser(id: ID!): DownloadUserResponse
@@ -14,5 +14,24 @@ en:
subject: "Your comments are ready for download from {0}"
download_link_ready: "Click here to download your comments from {0} as of {1}:"
download_archive: "Download Archive"
delete:
subject: "Your account for {0} is scheduled to be deleted"
body: |
A request to delete your account was received. Your account is scheduled for deletion on {1}.
After that time all of your comments will be removed from the site, all of your comments will be removed from our database, and your username and email address will be removed from our system.
If you change your mind you can sign into your account and cancel the request before your scheduled account deletion time.
deleted:
subject: "Your account for {0} has been deleted"
body: |
Your commenter account for {0} is now deleted. We're sorry to see you go!
If you'd like to re-join the discussion in the future, you can sign up for a new account.
If you'd like to give us feedback on why you left and what we can do to make the commenting experience better, please email us at {1}.
cancelDelete:
subject: "Your account deletion request for {0} has been cancelled"
body: "You have cancelled your account deletion request for {0}. Your account is now reactivated."
error:
DOWNLOAD_TOKEN_INVALID: "Your download link is not valid."
+8 -2
View File
@@ -10,8 +10,14 @@ const processUpdates = async (model, updates) => {
// Create a new batch operation.
const bulk = model.collection.initializeUnorderedBulkOp();
for (const { query, update } of updates) {
bulk.find(query).updateOne(update);
for (const { query, update, replace } of updates) {
if (update) {
bulk.find(query).updateOne(update);
} else if (replace) {
bulk.find(query).replaceOne(replace);
} else {
throw new Error('invalid update object provided');
}
}
// Execute the bulk update operation.