diff --git a/bin/cli b/bin/cli index df2253261..9713d6f40 100755 --- a/bin/cli +++ b/bin/cli @@ -1,14 +1,12 @@ #!/usr/bin/env node -/** - * Module dependencies. - */ - -require('./util'); const program = require('commander'); -const { head, map } = require('lodash'); -const Matcher = require('did-you-mean'); +// We're requiring this here so it'll setup some promise rejection hooks to log +// out. +require('./util'); + +// Setup the program. program .command('serve', 'serve the application') .command('db', 'run database commands') @@ -24,20 +22,3 @@ program 'provides utilities for interacting with the plugin system' ) .parse(process.argv); - -// If the command wasn't found, output help. -const commands = map(program.commands, '_name'); -const command = head(program.args); -if (!commands.includes(command)) { - const m = new Matcher(commands); - const similarCommands = m.list(command); - - console.error( - `cli '${command}' is not a talk cli command. See 'cli --help'.` - ); - if (similarCommands.length > 0) { - const sc = similarCommands.map(({ value }) => `\t${value}\n`).join(''); - console.error(`\nThe most similar commands are\n${sc}`); - } - process.exit(1); -} diff --git a/bin/cli-settings b/bin/cli-settings index 7f449e0d5..72f42672d 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -4,7 +4,8 @@ const util = require('./util'); const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); -const SettingsService = require('../services/settings'); +const Settings = require('../services/settings'); +const cache = require('../services/cache'); // Register the shutdown criteria. util.onshutdown([() => mongoose.disconnect()]); @@ -14,9 +15,12 @@ util.onshutdown([() => mongoose.disconnect()]); */ async function changeOrgName() { try { - let settings = await SettingsService.retrieve(); + await cache.init(); - let { organizationName } = await inquirer.prompt([ + // Get the original settings. + const settings = await Settings.retrieve('organizationName'); + + const { organizationName } = await inquirer.prompt([ { name: 'organizationName', message: 'Organization Name', @@ -25,9 +29,8 @@ async function changeOrgName() { ]); if (settings.organizationName !== organizationName) { - settings.organizationName = organizationName; - - await SettingsService.update(settings); + // Set the organization name if there was a mutation to it. + await Settings.update({ organizationName }); console.log('Settings were updated.'); } else { @@ -36,9 +39,9 @@ async function changeOrgName() { } catch (err) { console.error(err); util.shutdown(1); + } finally { + util.shutdown(); } - - util.shutdown(); } //============================================================================== diff --git a/bin/util.js b/bin/util.js index 648c79bcc..78664daa3 100644 --- a/bin/util.js +++ b/bin/util.js @@ -4,7 +4,8 @@ require('../services/env'); const debug = require('debug')('talk:util'); const { uniq } = require('lodash'); -const util = (module.exports = {}); +// Setup the utilities. +const util = {}; /** * Stores an array of functions that should be executed in the event that the @@ -15,7 +16,7 @@ util.toshutdown = []; /** * Calls all the shutdown functions and then ends the process. - * @param {Number} [defaultCode=0] default return code upon sucesfull shutdown. + * @param {Number} [defaultCode=0] default return code upon successful shutdown. */ util.shutdown = (defaultCode = 0, signal = null) => { if (signal) { @@ -63,3 +64,5 @@ process.on('unhandledRejection', err => { console.error(err); process.exit(1); }); + +module.exports = util; diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js index f3daeffc2..3505ef7b5 100644 --- a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js @@ -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 }); }; diff --git a/client/coral-embed-stream/src/tabs/stream/components/Comment.js b/client/coral-embed-stream/src/tabs/stream/components/Comment.js index 0983b11c4..8b1524839 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Comment.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.js @@ -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 (
- {this.renderComment()} - {activeReplyBox === comment.id && this.renderReplyBox()} + {isCommentDeleted(comment) ? ( + + ) : ( +
+ {this.renderComment()} + {activeReplyBox === comment.id && this.renderReplyBox()} +
+ )} {this.renderRepliesContainer()}
); diff --git a/client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.js b/client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.js index a10369850..95184dffd 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.js +++ b/client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.js @@ -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'); } diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 8dc8fe113..e30c12e06 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -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]); } diff --git a/docs/source/integrating/authentication.md b/docs/source/integrating/authentication.md index 2a78944a6..b7340caf5 100644 --- a/docs/source/integrating/authentication.md +++ b/docs/source/integrating/authentication.md @@ -74,7 +74,6 @@ example issuer and Talk must match: |------|----------------------| |`JWT_ISSUER`|`JWT_ISSUER`| |`JWT_AUDIENCE`|`JWT_AUDIENCE`| -|`JWT_AUDIENCE`|`JWT_AUDIENCE`| |`SECRET`|`JWT_SECRET`*| \* Note that secrets is a pretty complex topic, refer to the @@ -83,4 +82,4 @@ reference, the basic takeaway is that the secret used to sign the tokens issued by the issuer must be able to be verified by Talk. For an example of implementing the plugin, refer to [`tokenUserNotFound`](/talk/reference/server/#tokenUserNotFound) -reference. \ No newline at end of file +reference. diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 04fdcd366..8657905d9 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -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); } diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 064835f8f..e562c062c 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -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. diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 03b199e0e..b5c193e3d 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -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! diff --git a/locales/en.yml b/locales/en.yml index 045d37c46..3d2fe88c7 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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" diff --git a/models/schema/comment.js b/models/schema/comment.js index d9586e850..a211884ff 100644 --- a/models/schema/comment.js +++ b/models/schema/comment.js @@ -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: {}, diff --git a/package.json b/package.json index 9fe31aa39..1127dd05c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -79,11 +79,11 @@ "babel-polyfill": "^6.26.0", "babel-preset-es2015": "6.24.1", "babel-preset-react": "^6.23.0", - "bunyan-debug-stream": "^1.0.8", "bcryptjs": "^2.4.3", "bowser": "^1.7.2", "brotli-webpack-plugin": "^0.5.0", "bunyan": "^1.8.12", + "bunyan-debug-stream": "^1.0.8", "cli-table": "^0.3.1", "clipboard": "^1.7.1", "colors": "^1.1.2", @@ -98,7 +98,6 @@ "dataloader": "^1.3.0", "debug": "3.1.0", "dialog-polyfill": "^0.4.9", - "did-you-mean": "^0.0.1", "dotenv": "^4.0.0", "ejs": "^2.5.7", "env-rewrite": "^1.0.2", diff --git a/perms/constants/mutation.js b/perms/constants/mutation.js index 57aa254e7..2d4ae04cb 100644 --- a/perms/constants/mutation.js +++ b/perms/constants/mutation.js @@ -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', }; diff --git a/perms/constants/query.js b/perms/constants/query.js index 197c5d9f9..0c7f4024d 100644 --- a/perms/constants/query.js +++ b/perms/constants/query.js @@ -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', }; diff --git a/perms/reducers/mutation.js b/perms/reducers/mutation.js index 44f66cf88..8133fcd47 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -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: diff --git a/perms/reducers/query.js b/perms/reducers/query.js index ed507139d..5c8b17307 100644 --- a/perms/reducers/query.js +++ b/perms/reducers/query.js @@ -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']); diff --git a/plugin-api/beta/client/utils/index.js b/plugin-api/beta/client/utils/index.js index 3fe742569..aeb9841f9 100644 --- a/plugin-api/beta/client/utils/index.js +++ b/plugin-api/beta/client/utils/index.js @@ -8,4 +8,5 @@ export { getErrorMessages, getDefinitionName, getShallowChanges, + createDefaultResponseFragments, } from 'coral-framework/utils'; diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index e90a06122..5aa3063b3 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -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) { diff --git a/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.css b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.css new file mode 100644 index 000000000..0dada08b5 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.css @@ -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; +} \ No newline at end of file diff --git a/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.js b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.js new file mode 100644 index 000000000..38d968c1d --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.js @@ -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 ( +
+

+ {' '} + {t('delete_request.account_deletion_requested')} +

+

+ {t('delete_request.received_on')} + {deletionScheduledFor}. +

+

+ {t('delete_request.cancel_request_description')} + + {' '} + {t('delete_request.before')} {deletionScheduledOn} + . +

+
+ +
+
+ ); + } +} + +AccountDeletionRequestedSign.propTypes = { + cancelAccountDeletion: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, +}; + +export default AccountDeletionRequestedSign; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.css b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.css new file mode 100644 index 000000000..e524d990a --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.css @@ -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; + } +} \ No newline at end of file diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.js new file mode 100644 index 000000000..bbaca5b96 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.js @@ -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 ( +
+ +

+ {t('delete_request.delete_my_account')} +

+

+ {t('delete_request.delete_my_account_description')} +

+

+ {scheduledDeletionDate && + t( + 'delete_request.already_submitted_request_description', + moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a') + )} +

+ {scheduledDeletionDate ? ( + + ) : ( + + )} +
+ ); + } +} + +DeleteMyAccount.propTypes = { + requestAccountDeletion: PropTypes.func.isRequired, + cancelAccountDeletion: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, +}; + +export default DeleteMyAccount; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.css b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.css new file mode 100644 index 000000000..2735474da --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.css @@ -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; +} \ No newline at end of file diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.js new file mode 100644 index 000000000..9264bb75c --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.js @@ -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 ( + + + × + +

+ {t('delete_request.delete_my_account')} +

+ + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( + + )} +
+ ); + } +} + +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; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountFinalStep.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountFinalStep.js new file mode 100644 index 000000000..289cd6c17 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountFinalStep.js @@ -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 => ( +
+

+ {t('delete_request.your_request_submitted_description')} +

+ +
+ + {t('delete_request.your_account_deletion_scheduled')} + + + + + {moment(props.scheduledDeletionDate).format('MMM Do YYYY, h:mm a')} + + +
+ +

+ {t('delete_request.changed_your_mind')}{' '} + {t('delete_request.simply_go_to')} “ + {t('delete_request.cancel_account_deletion_request')}. + ” +

+ +

+ {t('delete_request.tell_us_why')}.{' '} + {t('delete_request.feedback_copy')}{' '} + + {props.organizationContactEmail} + . +

+ +
+ +
+
+); + +DeleteMyAccountFinalStep.propTypes = { + finish: PropTypes.func.isRequired, + scheduledDeletionDate: PropTypes.any.isRequired, + organizationContactEmail: PropTypes.string.isRequired, +}; + +export default DeleteMyAccountFinalStep; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep.css b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep.css new file mode 100644 index 000000000..7d02b4e6f --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep.css @@ -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; + } +} diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep0.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep0.js new file mode 100644 index 000000000..42cb3d5a3 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep0.js @@ -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 => ( +
+

+ {t('delete_request.step_0.you_are_attempting')} +

+ +
+ + +
+
+); + +DeleteMyAccountStep0.propTypes = { + goToNextStep: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, +}; + +export default DeleteMyAccountStep0; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep1.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep1.js new file mode 100644 index 000000000..3d069cfd1 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep1.js @@ -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 => ( +
+

{t('delete_request.step_1.subtitle')}

+

+ {t('delete_request.step_1.description', scheduledDeletionDelayHours)} +

+

{t('delete_request.step_1.subtitle_2')}

+

+ {t('delete_request.step_1.description_2', scheduledDeletionDelayHours)} +

+
+ + +
+
+); + +DeleteMyAccountStep1.propTypes = { + goToNextStep: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, +}; + +export default DeleteMyAccountStep1; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep2.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep2.js new file mode 100644 index 000000000..c96405f52 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep2.js @@ -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 => ( +
+

+ {t('delete_request.step_2.description')} +

+

+ {t('delete_request.step_2.to_download')} + + {t('delete_request.step_2.path')} + +

+
+ + +
+
+); + +DeleteMyAccountStep2.propTypes = { + goToNextStep: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, +}; + +export default DeleteMyAccountStep2; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep3.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep3.js new file mode 100644 index 000000000..31ed5c1b1 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountStep3.js @@ -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 ( +
+

+ {t('delete_request.step_3.subtitle')} +

+

+ {t('delete_request.step_3.description')} +

+ + +
+ + +
+
+ ); + } +} + +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; diff --git a/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js index 06fa1fa89..7af15b774 100644 --- a/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js +++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js @@ -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 {

{t('download_request.section_title')}

{t('download_request.you_will_get_a_copy')}{' '} - {t('download_request.download_rate')}. + {t('download_request.download_rate', downloadRateLimitDays)}.

{lastAccountDownloadDate && (

@@ -52,7 +63,7 @@ class DownloadCommentHistory extends Component {

)} {canRequestDownload ? ( -