diff --git a/.gitignore b/.gitignore index e8cb92653..24022b9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ plugins/* !plugins/talk-plugin-google-auth !plugins/talk-plugin-ignore-user !plugins/talk-plugin-like +!plugins/talk-plugin-local-auth !plugins/talk-plugin-love !plugins/talk-plugin-member-since !plugins/talk-plugin-mod @@ -50,10 +51,10 @@ plugins/* !plugins/talk-plugin-notifications-digest-hourly !plugins/talk-plugin-offtopic !plugins/talk-plugin-permalink -!plugins/talk-plugin-profile-settings !plugins/talk-plugin-profile-data !plugins/talk-plugin-remember-sort !plugins/talk-plugin-respect +!plugins/talk-plugin-rich-text !plugins/talk-plugin-slack-notifications !plugins/talk-plugin-sort-most-downvoted !plugins/talk-plugin-sort-most-liked @@ -67,7 +68,6 @@ plugins/* !plugins/talk-plugin-toxic-comments !plugins/talk-plugin-upvote !plugins/talk-plugin-viewing-options -!plugins/talk-plugin-rich-text **/node_modules/* yarn-error.log diff --git a/.nsprc b/.nsprc index da6cb9865..530a17a9f 100644 --- a/.nsprc +++ b/.nsprc @@ -2,6 +2,11 @@ "exceptions": [ "https://nodesecurity.io/advisories/531", "https://nodesecurity.io/advisories/532", - "https://nodesecurity.io/advisories/566" + "https://nodesecurity.io/advisories/566", + "https://nodesecurity.io/advisories/577", + "https://nodesecurity.io/advisories/594", + "https://nodesecurity.io/advisories/603", + "https://nodesecurity.io/advisories/611", + "https://nodesecurity.io/advisories/612" ] } diff --git a/README.md b/README.md index 6bdb7fda0..b0bc6e497 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ You’ve installed Talk on your server, and you’re preparing to launch it on y ## End-to-End Testing -Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com). +Talk uses [Nightwatch](http://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com). [![Browserstack](/public/img/browserstack_logo.png)](https://browserstack.com) 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-assets b/bin/cli-assets index 210b9f22e..5c1afb741 100755 --- a/bin/cli-assets +++ b/bin/cli-assets @@ -23,23 +23,33 @@ util.onshutdown([() => mongoose.disconnect()]); /** * Lists all the assets registered in the database. */ -async function listAssets() { +async function listAssets(opts) { try { let assets = await AssetModel.find({}).sort({ created_at: 1 }); - let table = new Table({ - head: ['ID', 'Title', 'URL'], - }); + switch (opts.format) { + case 'json': { + console.log(JSON.stringify(assets, null, 2)); + break; + } + default: { + let table = new Table({ + head: ['ID', 'Title', 'URL'], + }); - assets.forEach(asset => { - table.push([ - asset.id, - asset.title ? asset.title : '', - asset.url ? asset.url : '', - ]); - }); + assets.forEach(asset => { + table.push([ + asset.id, + asset.title ? asset.title : '', + asset.url ? asset.url : '', + ]); + }); + + console.log(table.toString()); + break; + } + } - console.log(table.toString()); util.shutdown(); } catch (e) { console.error(e); @@ -49,12 +59,13 @@ async function listAssets() { async function refreshAssets(ageString) { try { - const now = new Date().getTime(); - const ageMs = parseDuration(ageString); - const age = new Date(now - ageMs); + const query = AssetModel.find({}, { id: 1 }); + if (ageString) { + // An age was specified, so filter only those assets. + const ageMs = parseDuration(ageString); + const age = new Date(Date.now() - ageMs); - let assets = await AssetModel.find( - { + query.merge({ $or: [ { scraped: { @@ -65,16 +76,28 @@ async function refreshAssets(ageString) { scraped: null, }, ], - }, - { id: 1 } - ); + }); + } // Create a graph context. const ctx = Context.forSystem(); + // Load the assets. + const cursor = query.cursor(); + // Queue all the assets for scraping. - await Promise.all(assets.map(({ id }) => scraper.create(ctx, id))); - console.log('Assets were queued to be scraped'); + const promises = []; + + let asset = await cursor.next(); + while (asset) { + promises.push(scraper.create(ctx, asset.id)); + asset = await cursor.next(); + } + + await Promise.all(promises); + + console.log(`${promises.length} Assets were queued to be scraped.`); + util.shutdown(); } catch (e) { console.error(e); @@ -202,11 +225,17 @@ async function rewrite(search, replace, options) { program .command('list') + .option( + '--format ', + 'Specify the output format [table]', + /^(table|json)$/i, + 'table' + ) .description('list all the assets in the database') .action(listAssets); program - .command('refresh ') + .command('refresh [age]') .description('queues the assets that exceed the age requested') .action(refreshAssets); diff --git a/bin/cli-settings b/bin/cli-settings index 7f449e0d5..05c7f2888 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.select('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/cli-users b/bin/cli-users index a01980a20..bf34cce97 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -328,7 +328,7 @@ program .action(searchUsers); program - .command('set-role ') + .command('set-role ') .description('sets the role on a user') .action(setUserRole); 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/components/App.css b/client/coral-admin/src/components/App.css new file mode 100644 index 000000000..39c7cf942 --- /dev/null +++ b/client/coral-admin/src/components/App.css @@ -0,0 +1,11 @@ +:global { + html, body, #root, #root > div { + min-height: 100%; + } + + body { + margin: 0; + background-color: #FAFAFA; + font-family: 'Roboto', sans-serif; + } +} diff --git a/client/coral-admin/src/components/App.js b/client/coral-admin/src/components/App.js index 52535f91a..a7ab1e336 100644 --- a/client/coral-admin/src/components/App.js +++ b/client/coral-admin/src/components/App.js @@ -1,5 +1,6 @@ import React from 'react'; import ToastContainer from './ToastContainer'; +import './App.css'; import 'material-design-lite'; import AppRouter from '../AppRouter'; diff --git a/client/coral-admin/src/components/CommentAnimatedEdit.js b/client/coral-admin/src/components/CommentAnimatedEdit.js index 12280b204..f43cb9c89 100644 --- a/client/coral-admin/src/components/CommentAnimatedEdit.js +++ b/client/coral-admin/src/components/CommentAnimatedEdit.js @@ -29,7 +29,7 @@ const CommentAnimatedEdit = ({ children, body }) => { CommentAnimatedEdit.propTypes = { children: PropTypes.node, - body: PropTypes.string, + body: PropTypes.string.isRequired, }; export default CommentAnimatedEdit; diff --git a/client/coral-admin/src/components/CommentDeletedTombstone.css b/client/coral-admin/src/components/CommentDeletedTombstone.css new file mode 100644 index 000000000..81f5ddf7a --- /dev/null +++ b/client/coral-admin/src/components/CommentDeletedTombstone.css @@ -0,0 +1,5 @@ +.tombstone { + background-color: #f0f0f0; + padding: 1em; + color: #1a212f; +} \ No newline at end of file diff --git a/client/coral-admin/src/components/CommentDeletedTombstone.js b/client/coral-admin/src/components/CommentDeletedTombstone.js new file mode 100644 index 000000000..c04e97b88 --- /dev/null +++ b/client/coral-admin/src/components/CommentDeletedTombstone.js @@ -0,0 +1,9 @@ +import React from 'react'; +import styles from './CommentDeletedTombstone.css'; +import t from 'coral-framework/services/i18n'; + +const CommentDeletedTombstone = () => ( +
{t('framework.comment_is_deleted')}
+); + +export default CommentDeletedTombstone; diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js index fc150b840..f0bb68c0c 100644 --- a/client/coral-admin/src/components/Header.js +++ b/client/coral-admin/src/components/Header.js @@ -7,7 +7,6 @@ import styles from './Header.css'; import t from 'coral-framework/services/i18n'; import { Logo } from './Logo'; import { can } from 'coral-framework/services/perms'; -import ModerationIndicator from '../routes/Moderation/containers/Indicator'; import CommunityIndicator from '../routes/Community/containers/Indicator'; const CoralHeader = ({ @@ -32,7 +31,6 @@ const CoralHeader = ({ activeClassName={styles.active} > {t('configure.moderate')} - )} + + + ); + } + return (
  • ({ + updateQueries: { + CoralAdmin_UserDetail: prev => { + const increment = { + rejectedComments: { + $apply: count => (count < prev.totalComments ? count + 1 : count), + }, + }; + + const decrement = { + rejectedComments: { + $apply: count => (count > 0 ? count - 1 : 0), + }, + }; + + // If rejected then increment rejectedComments by one + if (status === 'REJECTED') { + const updated = update(prev, increment); + return updated; + } + + // If approved then decrement rejectedComments by one + if (status === 'ACCEPTED') { + const updated = update(prev, decrement); + return updated; + } + + return prev; + }, + }, + }), }, }; diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js index 2bbe1a230..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 }); }; @@ -84,7 +84,6 @@ class OrganizationSettings extends React.Component { render() { const { settings, slotPassthrough, canSave } = this.props; const hasErrors = this.state.errors.length; - return (

    {t('configure.organization_info_copy')}

    @@ -125,7 +124,7 @@ class OrganizationSettings extends React.Component { [styles.editable]: this.state.editing, })} onChange={this.updateEmail} - value={settings.organizationContactEmail} + value={settings.organizationContactEmail || ''} id={t('configure.organization_contact_email')} readOnly={!this.state.editing} /> diff --git a/client/coral-admin/src/routes/Configure/components/StreamSettings.js b/client/coral-admin/src/routes/Configure/components/StreamSettings.js index e6424848f..1f661fd72 100644 --- a/client/coral-admin/src/routes/Configure/components/StreamSettings.js +++ b/client/coral-admin/src/routes/Configure/components/StreamSettings.js @@ -82,6 +82,20 @@ class StreamSettings extends React.Component { this.props.updatePending({ updater }); }; + updateDisableCommenting = () => { + const updater = { + disableCommenting: { + $set: !this.props.settings.disableCommenting, + }, + }; + this.props.updatePending({ updater }); + }; + + updateDisableCommentingMessage = value => { + const updater = { disableCommentingMessage: { $set: value } }; + this.props.updatePending({ updater }); + }; + updateAutoClose = () => { const updater = { autoCloseStream: { $set: !this.props.settings.autoCloseStream }, @@ -192,6 +206,25 @@ class StreamSettings extends React.Component {   {t('configure.edit_comment_timeframe_text_post')} + +

    {t('configure.disable_commenting_desc')}

    +
    + +
    +
    { await this.props.updateSettings(this.props.pending); @@ -40,18 +41,16 @@ class ConfigureContainer extends React.Component { }; gotoNextRoute = () => { - const { nextRoute } = this.state; - if (nextRoute) { - this.props.router.push(nextRoute); - this.setState({ nextRoute: '' }); + if (this.nextRoute) { + this.props.router.push(this.nextRoute); + this.nextRoute = ''; } }; handleSectionChange = async section => { const nextRoute = `/admin/configure/${section}`; - - if (this.shouldShowSaveDialog()) { - await this.setState({ nextRoute }); + if (this.hasPendingData()) { + this.nextRoute = nextRoute; this.props.showSaveDialog(); } else { // Just go to the section @@ -59,20 +58,37 @@ class ConfigureContainer extends React.Component { } }; - shouldShowSaveDialog = () => { + navigationPrompt = e => { + if (this.hasPendingData()) { + const confirmationMessage = 'Changes that you made may not be saved.'; + e.returnValue = confirmationMessage; // Gecko, Trident, Chrome 34+ + return confirmationMessage; // Gecko, WebKit, Chrome <34 + } + }; + + hasPendingData = () => { return !!Object.keys(this.props.pending).length; }; routeLeave = ({ pathname }) => { - if (this.shouldShowSaveDialog()) { - this.setState({ nextRoute: pathname }); + if (this.hasPendingData()) { + this.nextRoute = pathname; this.props.showSaveDialog(); return false; } }; componentDidMount() { - this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave); + this.unregisterLeaveHook = this.props.router.setRouteLeaveHook( + this.props.route, + this.routeLeave + ); + window.addEventListener('beforeunload', this.navigationPrompt); + } + + componentWillUnmount() { + this.unregisterLeaveHook(); + window.removeEventListener('beforeunload', this.navigationPrompt); } render() { diff --git a/client/coral-admin/src/routes/Configure/containers/StreamSettings.js b/client/coral-admin/src/routes/Configure/containers/StreamSettings.js index 5bc8e69a9..faffb1aec 100644 --- a/client/coral-admin/src/routes/Configure/containers/StreamSettings.js +++ b/client/coral-admin/src/routes/Configure/containers/StreamSettings.js @@ -39,6 +39,8 @@ export default compose( autoCloseStream closedTimeout closedMessage + disableCommenting + disableCommentingMessage ${getSlotFragmentSpreads(slots, 'settings')} } `, diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css index 2dc03a503..0f8f91a97 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.css +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -85,6 +85,10 @@ font-weight: 300; } +.deleted { + background-color: #f0f0f0; +} + .moderateArticle { font-size: 14px; margin: 10px 0; diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 28dcf8ca8..97cdf9443 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -13,6 +13,7 @@ import IfHasLink from 'coral-admin/src/components/IfHasLink'; import cn from 'classnames'; import ApproveButton from 'coral-admin/src/components/ApproveButton'; import RejectButton from 'coral-admin/src/components/RejectButton'; +import CommentDeletedTombstone from '../../../components/CommentDeletedTombstone'; import t, { timeago } from 'coral-framework/services/i18n'; @@ -75,6 +76,27 @@ class Comment extends React.Component { asset: comment.asset, }; + if (!comment.body) { + return ( +
  • + +
  • + ); + } + return (
  • { - return props.root[`${props.activeTab}Count`]; - }; - moderate = accept => { const { acceptComment, @@ -139,12 +135,14 @@ class Moderation extends Component { const comments = root[activeTab]; - const activeTabCount = this.getActiveTabCount(); const menuItems = Object.keys(queueConfig).map(queue => ({ key: queue, name: queueConfig[queue].name, icon: queueConfig[queue].icon, - count: root[`${queue}Count`], + indicator: + ['premod', 'reported'].includes(queue) && root[queue].nodes.length > 0, + // TODO: Eventually we'll reintroduce counting + // count: root[`${props.queue}Count`] })); const slotPassthrough = { @@ -189,7 +187,6 @@ class Moderation extends Component { loadMore={this.loadMore} commentBelongToQueue={this.props.commentBelongToQueue} isLoadingMore={this.state.isLoadingMore} - commentCount={activeTabCount} currentUserId={this.props.currentUser.id} viewUserDetail={viewUserDetail} selectCommentId={props.selectCommentId} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js index 75a274eb3..27394c6c9 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import CountBadge from '../../../components/CountBadge'; +import Indicator from '../../../components/Indicator'; import styles from './ModerationMenu.css'; import { Icon } from 'coral-ui'; import { Link } from 'react-router'; @@ -32,7 +32,7 @@ const ModerationMenu = ({ asset = {}, items, getModPath, activeTab }) => { activeClassName={styles.active} > {queue.name}{' '} - + {queue.indicator && } ))} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index e47a162ef..c22843c8f 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -204,7 +204,7 @@ class ModerationQueue extends React.Component { } componentDidUpdate(prev) { - const { commentCount, selectedCommentId } = this.props; + const { selectedCommentId, hasNextPage } = this.props; const switchedToMultiMode = prev.singleView && !this.props.singleView; const switchedMode = prev.singleView !== this.props.singleView; @@ -212,7 +212,6 @@ class ModerationQueue extends React.Component { prev.selectedCommentId !== selectedCommentId && selectedCommentId; const moderatedLastComment = prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0; - const hasMoreComment = commentCount > 0; if (switchedToMultiMode) { // Reflow virtual list. @@ -223,7 +222,7 @@ class ModerationQueue extends React.Component { this.scrollToSelectedComment(); } - if (moderatedLastComment && hasMoreComment) { + if (moderatedLastComment && hasNextPage) { this.props.loadMore(); } } @@ -240,10 +239,7 @@ class ModerationQueue extends React.Component { const index = view.findIndex( ({ id }) => id === this.props.selectedCommentId ); - if ( - index === view.length - 1 && - this.getCommentCountWithoutDagling() !== this.props.commentCount - ) { + if (index === view.length - 1 && this.props.hasNextPage) { await this.props.loadMore(); this.selectDown(); return; @@ -467,7 +463,6 @@ ModerationQueue.propTypes = { acceptComment: PropTypes.func.isRequired, commentBelongToQueue: PropTypes.func.isRequired, cleanUpQueue: PropTypes.func.isRequired, - commentCount: PropTypes.number.isRequired, loadMore: PropTypes.func.isRequired, singleView: PropTypes.bool, isLoadingMore: PropTypes.bool, diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index f35255732..e3f35081c 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -314,11 +314,11 @@ class ModerationContainer extends Component { const currentQueueConfig = Object.assign({}, this.props.queueConfig); - if (premodEnabled && root.newCount === 0) { + if (premodEnabled && root.new.nodes.length === 0) { delete currentQueueConfig.new; } - if (!premodEnabled && root.premodCount === 0) { + if (!premodEnabled && root.premod.nodes.length === 0) { delete currentQueueConfig.premod; } @@ -402,7 +402,7 @@ const COMMENT_RESET_SUBSCRIPTION = gql` const LOAD_MORE_QUERY = gql` query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $tags:[String!], $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) { - comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags}) { + comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags, excludeDeleted: true}) { nodes { ...${getDefinitionName(Comment.fragments.comment)} } @@ -432,6 +432,7 @@ const withModQueueQuery = withQuery( ${Object.keys(queueConfig).map( queue => ` ${queue}: comments(query: { + excludeDeleted: true, statuses: ${ queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` @@ -455,9 +456,14 @@ const withModQueueQuery = withQuery( } ` )} - ${Object.keys(queueConfig).map( + ${ + '' + /* + TODO: eventually we'll reintroduce counting.. + Object.keys(queueConfig).map( queue => ` ${queue}Count: commentCount(query: { + excludeDeleted: true, statuses: ${ queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` @@ -476,7 +482,8 @@ const withModQueueQuery = withQuery( asset_id: $asset_id, }) ` - )} + )*/ + } asset(id: $asset_id) @skip(if: $allAssets) { id title diff --git a/client/coral-embed-stream/src/tabs/profile/components/Comment.css b/client/coral-embed-stream/src/tabs/profile/components/Comment.css index 9004a1e6c..c2bf841d5 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/Comment.css +++ b/client/coral-embed-stream/src/tabs/profile/components/Comment.css @@ -31,6 +31,7 @@ font-weight: bold; font-size: 12px; color: #757575; + cursor: pointer; } .commentSummary { diff --git a/client/coral-embed-stream/src/tabs/profile/components/Comment.js b/client/coral-embed-stream/src/tabs/profile/components/Comment.js index 171172dab..badeb4262 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/Comment.js +++ b/client/coral-embed-stream/src/tabs/profile/components/Comment.js @@ -11,16 +11,6 @@ import { getTotalReactionsCount } from 'coral-framework/utils'; import t from 'coral-framework/services/i18n'; class Comment extends React.Component { - goToStory = () => { - this.props.navigate(this.props.comment.asset.url); - }; - - goToConversation = () => { - this.props.navigate( - `${this.props.comment.asset.url}?commentId=${this.props.comment.id}` - ); - }; - render() { const { comment, root } = this.props; const reactionCount = getTotalReactionsCount(comment.action_summaries); @@ -76,8 +66,8 @@ class Comment extends React.Component {
    {t('common.story')}:{' '} {comment.asset.title ? comment.asset.title : comment.asset.url} @@ -87,7 +77,13 @@ class Comment extends React.Component {
    • - + {t('view_conversation')} diff --git a/client/coral-embed-stream/src/tabs/profile/components/Profile.js b/client/coral-embed-stream/src/tabs/profile/components/Profile.js index 0bc90be0a..afac126d8 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/Profile.js +++ b/client/coral-embed-stream/src/tabs/profile/components/Profile.js @@ -4,13 +4,32 @@ import Slot from 'coral-framework/components/Slot'; import styles from './Profile.css'; import TabPanel from '../containers/TabPanel'; -const Profile = ({ username, emailAddress, root, slotPassthrough }) => { +const DefaultProfileHeader = ({ username, emailAddress }) => ( +
      +

      {username}

      + {emailAddress ?

      {emailAddress}

      : null} +
      +); + +DefaultProfileHeader.propTypes = { + username: PropTypes.string, + emailAddress: PropTypes.string, +}; + +const Profile = ({ id, username, emailAddress, root, slotPassthrough }) => { return (
      -
      -

      {username}

      - {emailAddress ?

      {emailAddress}

      : null} -
      +
      @@ -18,6 +37,7 @@ const Profile = ({ username, emailAddress, root, slotPassthrough }) => { }; Profile.propTypes = { + id: PropTypes.string, username: PropTypes.string, emailAddress: PropTypes.string, root: PropTypes.object, diff --git a/client/coral-embed-stream/src/tabs/profile/containers/Profile.js b/client/coral-embed-stream/src/tabs/profile/containers/Profile.js index 84584ae5d..4a84f1810 100644 --- a/client/coral-embed-stream/src/tabs/profile/containers/Profile.js +++ b/client/coral-embed-stream/src/tabs/profile/containers/Profile.js @@ -30,6 +30,7 @@ class ProfileContainer extends Component { return ( ); })} @@ -744,8 +747,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-embed-stream/src/tabs/stream/components/Stream.js b/client/coral-embed-stream/src/tabs/stream/components/Stream.js index cc4e8e939..a07f8ff8b 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Stream.js @@ -4,6 +4,7 @@ import StreamError from './StreamError'; import Comment from '../containers/Comment'; import BannedAccount from '../../../components/BannedAccount'; import ChangeUsername from '../containers/ChangeUsername'; +import Markdown from 'coral-framework/components/Markdown'; import Slot from 'coral-framework/components/Slot'; import InfoBox from './InfoBox'; import { can } from 'coral-framework/services/perms'; @@ -181,7 +182,9 @@ class Stream extends React.Component { setActiveReplyBox={setActiveReplyBox} activeReplyBox={activeReplyBox} notify={notify} - disableReply={asset.isClosed} + disableReply={ + asset.isClosed || asset.settings.disableCommenting + } postComment={postComment} currentUser={currentUser} postFlag={postFlag} @@ -215,7 +218,7 @@ class Stream extends React.Component { currentUser, } = this.props; const { keepCommentBox } = this.state; - const open = !asset.isClosed; + const open = !(asset.isClosed || asset.settings.disableCommenting); const banned = get(currentUser, 'status.banned.status'); const suspensionUntil = get(currentUser, 'status.suspension.until'); @@ -293,7 +296,13 @@ class Stream extends React.Component { )}
    ) : ( -

    {asset.settings.closedMessage}

    +
    + {asset.isClosed ? ( +

    {asset.settings.closedMessage}

    + ) : ( + + )} +
    )} diff --git a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js index 0a7a1951c..d6ca27558 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -265,7 +265,7 @@ StreamContainer.propTypes = { commentClassNames: PropTypes.array, setActiveStreamTab: PropTypes.func, postFlag: PropTypes.func, - postDontAgree: PropTypes.func, + postDontAgree: PropTypes.func.isRequired, deleteAction: PropTypes.func, showSignInDialog: PropTypes.func, currentUser: PropTypes.object, @@ -372,6 +372,7 @@ const slots = [ 'streamTabsPrepend', 'streamTabPanes', 'streamFilter', + 'stream', ]; const fragments = { @@ -433,6 +434,8 @@ const fragments = { questionBoxIcon closedTimeout closedMessage + disableCommenting + disableCommentingMessage charCountEnable charCount requireEmailConfirmation diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 98416d68d..b9330ba62 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -177,8 +177,8 @@ button.comment__action-button[disabled], } .talk-plugin-flags-popup-header { - font-weight: bolder; - font-size: 1.33rem; + font-weight: bold; + font-size: 1rem; margin-bottom: 10px; } diff --git a/client/coral-framework/components/Popup.js b/client/coral-framework/components/Popup.js index 53d8f7cd8..339ddb806 100644 --- a/client/coral-framework/components/Popup.js +++ b/client/coral-framework/components/Popup.js @@ -37,7 +37,8 @@ export default class Popup extends Component { this.onBlur(); }; - // Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari. + // Use `onunload` instead of `onbeforeunload` which is not supported in iOS + // Safari. this.ref.onunload = () => { this.onUnload(); @@ -46,10 +47,15 @@ export default class Popup extends Component { } this.resetCallbackInterval = setInterval(() => { - if (this.ref && this.ref.onload === null) { - clearInterval(this.resetCallbackInterval); - this.resetCallbackInterval = null; - this.setCallbacks(); + try { + if (this.ref && this.ref.onload === null) { + clearInterval(this.resetCallbackInterval); + this.resetCallbackInterval = null; + this.setCallbacks(); + } + } catch (err) { + // We could be getting a security exception here if the login page + // gets redirected to another domain to authenticate. } }, 50); diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js index c5704f719..778214b8b 100644 --- a/client/coral-framework/graphql/fragments.js +++ b/client/coral-framework/graphql/fragments.js @@ -26,6 +26,8 @@ export default { 'UpdateAssetSettingsResponse', 'UpdateAssetStatusResponse', 'UpdateSettingsResponse', - 'ChangePasswordResponse' + 'ChangePasswordResponse', + 'UpdateEmailAddressResponse', + 'AttachLocalAuthResponse' ), }; diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index 4d429ed19..7b9eee141 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -1,6 +1,5 @@ import { gql } from 'react-apollo'; import withMutation from '../hocs/withMutation'; -import update from 'immutability-helper'; function convertItemType(item_type) { switch (item_type) { @@ -168,36 +167,6 @@ export const withSetCommentStatus = withMutation( errors: null, }, }, - updateQueries: { - CoralAdmin_UserDetail: prev => { - const increment = { - rejectedComments: { - $apply: count => - count < prev.totalComments ? count + 1 : count, - }, - }; - - const decrement = { - rejectedComments: { - $apply: count => (count > 0 ? count - 1 : 0), - }, - }; - - // If rejected then increment rejectedComments by one - if (status === 'REJECTED') { - const updated = update(prev, increment); - return updated; - } - - // If approved then decrement rejectedComments by one - if (status === 'ACCEPTED') { - const updated = update(prev, decrement); - return updated; - } - - return prev; - }, - }, update: proxy => { const fragment = gql` fragment Talk_SetCommentStatus_Comment on Comment { diff --git a/client/coral-framework/hocs/withSetUsername.js b/client/coral-framework/hocs/withSetUsername.js index 05323efa1..3a8f65a22 100644 --- a/client/coral-framework/hocs/withSetUsername.js +++ b/client/coral-framework/hocs/withSetUsername.js @@ -59,6 +59,7 @@ const withSetUsername = hoistStatics(WrappedComponent => { } const changeSet = { success: false, loading: false, error }; this.setState(changeSet); + throw error; } }; diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 66792b9b3..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]); } @@ -237,7 +245,11 @@ export function getTotalReactionsCount(actionSummaries) { // Like lodash merge but does not recurse into arrays. export function mergeExcludingArrays(objValue, srcValue) { - if (typeof srcValue === 'object' && !Array.isArray(srcValue)) { + if ( + typeof srcValue === 'object' && + !Array.isArray(srcValue) && + srcValue !== null + ) { return assignWith({}, objValue, srcValue, mergeExcludingArrays); } return srcValue; diff --git a/client/coral-framework/utils/user.js b/client/coral-framework/utils/user.js index c6b2ba83d..9b7ab65e1 100644 --- a/client/coral-framework/utils/user.js +++ b/client/coral-framework/utils/user.js @@ -1,6 +1,7 @@ import get from 'lodash/get'; import mapValues from 'lodash/mapValues'; import toPairs from 'lodash/toPairs'; +import moment from 'moment'; /** * getReliability @@ -70,3 +71,18 @@ export const getActiveStatuses = user => { return toPairs(mapValues(statusMap, fn => fn(user))).filter(x => x[1]); }; + +/** + * canUsernameBeUpdated + * retrieves boolean whether a username can be updated or not + */ + +export const canUsernameBeUpdated = status => { + const oldestEditTime = moment() + .subtract(14, 'days') + .toDate(); + + return !status.username.history.some(({ created_at }) => + moment(created_at).isAfter(oldestEditTime) + ); +}; diff --git a/client/coral-ui/components/PopupMenu.css b/client/coral-ui/components/PopupMenu.css index 35544fbad..e6a084736 100644 --- a/client/coral-ui/components/PopupMenu.css +++ b/client/coral-ui/components/PopupMenu.css @@ -9,7 +9,7 @@ box-sizing: border-box; background: white; border-radius: 3px; - padding: 20px 10px; + padding: 10px 10px; z-index: 300; right: 1%; } diff --git a/config.js b/config.js index a99e08923..6a1f999a7 100644 --- a/config.js +++ b/config.js @@ -212,6 +212,13 @@ const CONFIG = { RECAPTCHA_PUBLIC: process.env.TALK_RECAPTCHA_PUBLIC, RECAPTCHA_SECRET: process.env.TALK_RECAPTCHA_SECRET, + // RECAPTCHA_WINDOW is the rate limit's time interval + RECAPTCHA_WINDOW: process.env.TALK_RECAPTCHA_WINDOW || '10m', + + // After RECAPTCHA_INCORRECT_TRIGGER incorrect attempts, recaptcha will be required. + RECAPTCHA_INCORRECT_TRIGGER: + process.env.TALK_RECAPTCHA_INCORRECT_TRIGGER || 5, + // WEBSOCKET_LIVE_URI is the absolute url to the live endpoint. WEBSOCKET_LIVE_URI: process.env.TALK_WEBSOCKET_LIVE_URI || null, diff --git a/docs/_config.yml b/docs/_config.yml index 6c0da733b..ac90dbc96 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -41,7 +41,7 @@ relative_link: false future: true highlight: enable: false - + # Home page setting # path: Root path for your blogs index page. (default = '') # per_page: Posts displayed per page. (0 = disable pagination) @@ -114,6 +114,8 @@ sidebar: url: /integrating/styling-css/ - title: Translations and i18n url: /integrating/translations-i18n/ + - title: GDPR Compliance + url: /integrating/gdpr/ - title: Product Guide children: - title: How Talk Works diff --git a/docs/source/02-02-advanced-configuration.md b/docs/source/02-02-advanced-configuration.md index b2ad29ad0..5b638401d 100644 --- a/docs/source/02-02-advanced-configuration.md +++ b/docs/source/02-02-advanced-configuration.md @@ -316,6 +316,18 @@ default to providing only a time based lockout. Refer to [reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information on getting an account setup. +## TALK_RECAPTCHA_WINDOW + +The rate limit time interval that there can be [TALK_RECAPTCHA_INCORRECT_TRIGGER](#talk_recaptcha_incorrect_trigger) incorrect attempts until the reCAPTCHA is +marked as required, parsed by +[ms](https://www.npmjs.com/package/ms). (Default `10m`) + +## TALK_RECAPTCHA_INCORRECT_TRIGGER + +The number of times that an incorrect login can be entered before within a time +perioud indicated by [TALK_RECAPTCHA_WINDOW](#talk_recaptcha_window) until the +reCAPTCHA is marked as required. (Default `5`) + ## TALK_REDIS_CLIENT_CONFIGURATION Configuration overrides for the redis client configuration in a JSON encoded @@ -531,4 +543,4 @@ Sets the logging level for the context logger (from [Bunyan](https://github.com/ A JSON string representing the configuration passed to the [fetch](https://www.npmjs.com/package/node-fetch) call for the scraper. It can be used to set an authorization header, or change the user agent. (Default -`{}`) \ No newline at end of file +`{}`) diff --git a/docs/source/03-01-product-guide-how-talk-works.md b/docs/source/03-01-product-guide-how-talk-works.md index 9463f74fe..8d221c8e5 100644 --- a/docs/source/03-01-product-guide-how-talk-works.md +++ b/docs/source/03-01-product-guide-how-talk-works.md @@ -28,8 +28,7 @@ Plugins are additional functionality which are optional to use with Talk. You can turn these on or off, depending on your specific needs. Plugins are either part of our core plugins, which ship with Talk, or they are developed by 3rd parties and either used privately and internally, or are open sourced for use -across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](/talk/default-plugins/) -and [Additional Plugins](/talk/additional-plugins/) pages. +across the greater community. You can explore the plugins we offer by visiting our [Plugin Directory](/talk/plugins-directory/). ## Recipes diff --git a/docs/source/03-03-product-guide-moderator-features.md b/docs/source/03-03-product-guide-moderator-features.md index 9fbda6a18..abb2c454a 100644 --- a/docs/source/03-03-product-guide-moderator-features.md +++ b/docs/source/03-03-product-guide-moderator-features.md @@ -170,8 +170,9 @@ moderators. All your team and commenters show in the People sub-tab. From here, you can manage your team members’ roles (Admins, Moderators, Staff), as well as search -for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ### -Configure +for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). + +### Configure See [Configuring Talk](/talk/configuring-talk/). diff --git a/docs/source/03-05-product-guide-toxic-comments.md b/docs/source/03-05-product-guide-toxic-comments.md index ddbb4f9ff..9e6ea2a6e 100644 --- a/docs/source/03-05-product-guide-toxic-comments.md +++ b/docs/source/03-05-product-guide-toxic-comments.md @@ -50,7 +50,7 @@ trying to improve a broken part of the internet. ## How do I add the Toxic Comments plugin? To enable this behavior, visit the -[talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) +[talk-plugin-toxic-comments](/talk/plugin/talk-plugin-toxic-comments) plugin documentation. diff --git a/docs/source/03-07-product-guide-trust.md b/docs/source/03-07-product-guide-trust.md index c27c59dcc..72248e7d1 100644 --- a/docs/source/03-07-product-guide-trust.md +++ b/docs/source/03-07-product-guide-trust.md @@ -3,47 +3,53 @@ title: Trust permalink: /trust/ --- -Trust is a set of components within Talk that incorporate automated moderation -features based on a user's previous behavior. +Trust is a set of components within Talk that incorporate basic automated moderation features based on a user's previous behavior. ## User Karma Score -Using Trust’s calculations, Talk will automatically pre-moderate comments of -users who have a negative karma score. All users start out with a `0` neutral -karma score. If they have a comment approved by a moderator, their score -increases by `1`; if they have a comment rejected by a moderator, it decreases -by `1`. When a commenter is labeled as Unreliable, their comments must be -moderated before they are posted. +Using Trust’s calculations, Talk will automatically hold back, move to the Reported queue, and tag with a 'History' marker, any comments by users who have an Unreliable karma score. (This is for sites who practice post-moderation. If you set pre-moderation of all comments sitewide, this feature has limited use.) -When a commenter has one comment rejected, their next comment must be moderated -once in order to post freely again. If they instead get rejected again, then -they must have two of their comments approved in order to get added back to the -queue. +All users start out with a Neutral karma score (`0`). If they have a comment approved by a moderator, their score increases by `1`; if they have a comment rejected by a moderator, it decreases by `1`. When a commenter's score is labeled as Unreliable, their comments must be approved from the Reported queue before they are posted. Commenters are shown a message stating that a moderator will review their comment shortly. Here are the default thresholds: ```text --2 and lower: Unreliable --1 to +2: Neutral -+3 and higher: Reliable +-1 and lower: Unreliable +0 to +1: Neutral ++2 and higher: Reliable (we don't do anything with this label right now) ``` -You can configure your own Trust thresholds by using [TRUST_THRESHOLD](/talk/advanced-configuration/#trust-thresholds) in your -configuration. +So in this case, when a new commenter has their first comment rejected, their user karma score becomes `-1`, which triggers the Unreliable threshhold, and they must then have a comment approved by a moderator in order to post freely again. Until that occurs, all of their comments will be held back temporarily in the Reported queue, marked with a `History` tag. + +If their next comment is also rejected, their user karma score is now `-2`, and they must have two comments approved in order to reach a Neutral score, and post without pre-approval. + +We strongly recommend not telling your community how this system works, or where the threshholds lie. Firstly, they might try to game the system to meet approval, and secondly, it makes it harder for you to change the threshhold in the future. We suggest using language such as "We hold back comments for approval for a variety of reasons, including content, account history, and more." + +If you see that a high proportion of first-time commenters on your site are abusive, you might want to change the threshhold to `0`, at least temporarily. You can configure your own Trust thresholds by using [TRUST_THRESHOLD](/talk/advanced-configuration/#trust-thresholds) in your site configuration. ## Reliable and Unreliable Flaggers Trust also calculates how reliable users are in terms of the comments they report. This information is displayed to moderators in the User History drawer, -which is accessed by clicking on a user’s name in the Admin. +which is accessed by clicking on a user’s name in the Admin. Currently, no other action is taken based on this score. If a user's reports mostly match what moderators reject, their Report status will display to moderators as Reliable in the user information drawer. If a user's reports mostly differ from what moderators reject, their Report status will show as Unreliable. -If we don't have enough reports to make a call, or the reports even out, their +If Talk doesn't have enough reports to make a call, or the reports even out, their status is Neutral. +Here are the default thresholds: + +```text +-1 and lower: Unreliable +0 to +1: Neutral ++2 and higher: Reliable +``` +You can configure your own Trust thresholds by using [TRUST_THRESHOLD](/talk/advanced-configuration/#trust-thresholds) in your +configuration. + Note: Report Karma doesn't include reports of "I don't agree with this comment". diff --git a/docs/source/03-08-gdpr.md b/docs/source/03-08-gdpr.md new file mode 100644 index 000000000..b6689be0e --- /dev/null +++ b/docs/source/03-08-gdpr.md @@ -0,0 +1,54 @@ +--- +title: GDPR Compliance +permalink: /integrating/gdpr/ +--- + +In order to facilitate compliance with the +[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you +can enable the following plugins: + +- [talk-plugin-auth](/talk/plugin/talk-plugin-auth) - to facilitate username and password changes +- [talk-plugin-local-auth](/talk/plugin/talk-plugin-local-auth) - to facilitate email changes and email association +- [talk-plugin-profile-data](/talk/plugin/talk-plugin-profile-data) - to facilitate account download and deletion + +Even if you don't reside in a location where GDPR will apply, it is recommended +to enable these features as a best practice to provide your users with control over their +own data. + +## GPDR Feature Overview + +Integrating our GDPR tools will give your users and organizations the following benefits: + +- **Download my comment data**: Users can request a download of their comments. An email with a link is emailed to them to download a CSV with each comment they've made, what story it was made on, and the comment's ID and timestamp. +- **Delete my account**: Users can request deletion of their account. Deleted account requests are pending for 24 hours to allow the user to download their comments, or to change their mind and reactivate their account before the expiry. Account deletions remove all of their comments from the site, all their comments and actions from the database, and their account info from our system. +- **Add an email to an Oauth/external account**: Users are prompted to add an email to their non-Talk account (Facebook, Google, external, etc) so that they can take part in GDPR and other features requiring email communication. +- **Change my username**: Users can update their username. This is capped at once every 2 weeks. +- **Change my email**: Users can change their email. +- **Change my password**: Users can change their password. + +## Custom Authentication Solutions + +As many of the newsrooms who have integrated Talk have followed our guides on +[Integrating Authentication](/talk/integrating/authentication/), we have also +provided tools for those newsrooms to integrate GDPR features into their +existing workflows. + +### Account Data + +Through the [talk-plugin-profile-data](/talk/plugin/talk-plugin-profile-data) +plugin we allow users to download and delete their account data easily. For +custom integrations, this isn't always possible, so we instead provide some +GraphQL mutations designed to allow you to integrate it into your existing user +interfaces or exports. + +- `downloadUser(id: ID!)` - lets you grab the direct link to download a users + account in a zip format. From there, you can integrate it into your existing + data export or simply proxy it to the user to allow them to download it + elsewhere in your UI. +- `delUser(id: ID!)` - lets you delete the specified user + +**Note: These mutations require an administrative token** + +If you would prefer to write your own user interfaces or integrate it into your +own, you can disable the client plugin for [talk-plugin-profile-data](/talk/plugin/talk-plugin-profile-data) +but keep the server side plugin active (See [Server and Client Plugins](/talk/plugins/#server-and-client-plugins) for more information). diff --git a/docs/source/05-03-when-youve-installed-talk.md b/docs/source/05-03-when-youve-installed-talk.md index 7bd775469..6e9a3e07e 100644 --- a/docs/source/05-03-when-youve-installed-talk.md +++ b/docs/source/05-03-when-youve-installed-talk.md @@ -70,7 +70,7 @@ The most important predictors of the success of an online community are: * Utilize [our Toxic Comments plugin](https://blog.coralproject.net/toxic-avenging/), developed with Google Jigsaw, to improve commenter behavior and use AI to help identify and prevent the most abusive comments from appearing on your site. -* Enable our [Akismet plugin](https://coralproject.github.io/talk/additional-plugins/#talk-plugin-akismet) to keep spam from appearing in your comments. +* Enable our [Akismet plugin](/talk/plugin/talk-plugin-akismet) to keep spam from appearing in your comments. * Use keyboard shortcuts in the moderation queue to moderate quickly (type '?' in the moderation view to see the list of shortcuts), and if there is a sudden deluge of comments, ask for someone in your newsroom to help you moderate. Talk will notify you in the moderation interface if someone else moderates a comment that's already on your screen. * Click on a community member's name in the moderation interface to review all their comments, and see if there is a clear pattern of abuse among their Rejected comments. You can also select all their recent comments and delete them in bulk.![A screenshot of the moderation interface with the user drawer on display. Pink arrows indicate the user name, which can be clicked to open the drawer, and the drawer itself.](http://blog.coralproject.net/wp-content/uploads/2018/03/userdrawer.jpg) @@ -83,7 +83,7 @@ The most important predictors of the success of an online community are: ![A screenshot of the comments stream with a moderation menu popped out and a large pink arrow pointing to the caret where moderators can unfold the moderation menu](http://blog.coralproject.net/wp-content/uploads/2018/03/caret-mod.jpg) -* If you find new commenters are causing a lot of trouble with their first comments, consider changing [the User Karma threshold](https://coralproject.github.io/talk/trust/) from negative one to zero, forcing every new commenter's first comment into pre-moderation. +* If you find new commenters are causing a lot of trouble with their first comments, consider changing [the User Karma threshold](/talk/trust/) from negative one to zero, forcing every new commenter's first comment into pre-moderation. #### Effectively diff --git a/docs/source/_data/plugins.yml b/docs/source/_data/plugins.yml index a5621b0d6..0e09de210 100644 --- a/docs/source/_data/plugins.yml +++ b/docs/source/_data/plugins.yml @@ -3,10 +3,17 @@ tags: - moderation - name: talk-plugin-auth - description: Enables internal authentication from Coral. + description: Enables internal sign-in authentication. tags: - default - auth + - gdpr +- name: talk-plugin-local-auth + description: Enables email and password based authentication. + tags: + - default + - auth + - gdpr - name: talk-plugin-author-menu description: Enables the comment author name plugin area. tags: diff --git a/docs/source/api/slots.md b/docs/source/api/slots.md index da57c0ad6..a2e7cc04d 100644 --- a/docs/source/api/slots.md +++ b/docs/source/api/slots.md @@ -33,8 +33,7 @@ By default, Talk has various plugins provided by default. We can see this in `pl "talk-plugin-sort-most-respected", "talk-plugin-sort-newest", "talk-plugin-sort-oldest", - "talk-plugin-viewing-options", - "talk-plugin-profile-settings" + "talk-plugin-viewing-options" ] } ``` diff --git a/docs/source/contact.md b/docs/source/contact.md index 029652fea..eba8e08c1 100644 --- a/docs/source/contact.md +++ b/docs/source/contact.md @@ -5,8 +5,8 @@ permalink: /contact/ ## How can I get help integrating Talk into my newsroom? -We're here to help with newsrooms of all sizes. Email our Integration Engineer -([jeff@mozillafoundation.org](mailto:jeff@mozillafoundation.org)) to set up a meeting. +We're here to help with newsrooms of all sizes. Email our Support Team +([support@coralproject.net](mailto:support@coralproject.net)) to set up a meeting. ## How do I request a feature or submit a bug? @@ -14,4 +14,4 @@ The best way is to [submit a GitHub issue](https://github.com/coralproject/talk/ ## How can our dev team contribute to Talk? -We are lucky to work with newsroom dev teams and individual contributors who span the world, and come from newsrooms of all sizes. You can read our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md) to get started, but feel free to reach out to us via GitHub, or get in touch directly with Jeff via jeff@mozillafoundation.org. +We are lucky to work with newsroom dev teams and individual contributors who span the world, and come from newsrooms of all sizes. You can read our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md) to get started, but feel free to reach out to us via GitHub, or get in touch with us directly via support@coralproject.net. 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/docs/source/integrating/configuring-comment-stream.md b/docs/source/integrating/configuring-comment-stream.md index 110560de0..613535a13 100644 --- a/docs/source/integrating/configuring-comment-stream.md +++ b/docs/source/integrating/configuring-comment-stream.md @@ -65,10 +65,6 @@ First, you'll enable `talk-plugin-author-menu`, as this houses the Ignore button And then we will enable the Ignore User plugin: `talk-plugin-ignore-user`. -And finally, we will need to enable Profile Settings; this is the tab on My Profile > Settings where commenters can manage their Ignored Users list. - -`talk-plugin-profile-settings` - ### Featured Comments To enable the featuring of comments, you'll need to activate `talk-plugin-featured-comments`. If you would like the Featured Comments tab to be the default tab you land on for the stream, you will need to set the default tab ENV variable: diff --git a/docs/source/integrating/notifications.md b/docs/source/integrating/notifications.md index 470632907..a5f625083 100644 --- a/docs/source/integrating/notifications.md +++ b/docs/source/integrating/notifications.md @@ -36,10 +36,6 @@ Adding the `talk-plugin-notifications` plugin will also enable the `notification See https://github.com/coralproject/talk/blob/8b669a31c551a042f0f079d8cfc16825673eb8f0/plugins/talk-plugin-notifications-reply/index.js for an example. -### Commenter Notification Settings - -Note that notifications REQUIRE the `talk-plugin-profile-settings` plugin; this is where on My Profile commenters will enable and manage their notification settings. - ### Notification Categories Talk currently supports the following Notifications options out of the box: diff --git a/docs/source/plugin/talk-plugin-local-auth.md b/docs/source/plugin/talk-plugin-local-auth.md new file mode 120000 index 000000000..918f29118 --- /dev/null +++ b/docs/source/plugin/talk-plugin-local-auth.md @@ -0,0 +1 @@ +../../../plugins/talk-plugin-local-auth/README.md \ No newline at end of file diff --git a/docs/source/plugins/overview.md b/docs/source/plugins/overview.md index 26230dc00..046f4db87 100644 --- a/docs/source/plugins/overview.md +++ b/docs/source/plugins/overview.md @@ -9,11 +9,16 @@ functionality. We provide methods to inject behavior into the server side and the client side application to affect different parts of the application life cycle. -## Recipes +## Server and Client Plugins -Recipes are plugin templates provided by the Coral Core team. Developers can use -these recipes to build their own plugins. You can find all the Talk recipes -here: [github.com/coralproject/talk-recipes](https://github.com/coralproject/talk-recipes/). +When you're adding a plugin to Talk, you can specify it in the `client` and/or +the `server` section. If you only want to enable the server side component of a +plugin, you simply only specify the plugin in the `server` section. If you only +want the client side plugin, the `client` section. + +Plugins listed in the [Plugins Directory](/talk/plugins-directory/) will +indicate if they have/support a client/server plugin, and should be activated +accordingly. ## Plugin Registration @@ -117,3 +122,9 @@ assets inside the image as well. For more information on the onbuild image, refer to the [Installation from Docker](/talk/installation-from-docker/) documentation. + +## Recipes + +Recipes are plugin templates provided by the Coral Core team. Developers can use +these recipes to build their own plugins. You can find all the Talk recipes +here: [github.com/coralproject/talk-recipes](https://github.com/coralproject/talk-recipes/). diff --git a/docs/source/plugins/plugins-directory.md b/docs/source/plugins/plugins-directory.md index ca9b26734..9161d0d68 100644 --- a/docs/source/plugins/plugins-directory.md +++ b/docs/source/plugins/plugins-directory.md @@ -3,8 +3,9 @@ title: Plugins Directory permalink: /plugins-directory/ layout: plugins data: plugins +class: plugins --- Talk provides a growing ecosystem of plugins that interact with our extensive Server and Client API's. Below you can search for a plugin to use with Talk and -discover what their requirements and configuration are. \ No newline at end of file +discover what their requirements and configuration are. diff --git a/docs/themes/coral/layout/plugins.swig b/docs/themes/coral/layout/plugins.swig index 959908105..adb60be30 100644 --- a/docs/themes/coral/layout/plugins.swig +++ b/docs/themes/coral/layout/plugins.swig @@ -4,9 +4,9 @@

    {{ page.title }}


    {% endif %} - + {{ page.content }} - +
    @@ -15,6 +15,7 @@ Enter a few keywords about the plugin to filter the list
    +
    {% for plugin in _.sortBy(site.data[page.data], 'name') %}
    @@ -25,7 +26,7 @@ {% if plugin.tags %}

    {% for tag in plugin.tags %} - {{ tag }} + {{ tag }} {% endfor %}

    {% endif %} @@ -35,4 +36,4 @@ {% endfor %} - \ No newline at end of file + diff --git a/docs/themes/coral/source/css/talk.scss b/docs/themes/coral/source/css/talk.scss index 391337876..afbd6f3af 100644 --- a/docs/themes/coral/source/css/talk.scss +++ b/docs/themes/coral/source/css/talk.scss @@ -448,3 +448,17 @@ a.brand { .plugin { display: none; } + +.badge-tag { + font-family: monospace; +} + +.badge-tag-default { + background: #28a745; + color: #fff; +} + +.badge-tag-gdpr { + background: rgb(0, 102, 176); + color: #fff; +} diff --git a/docs/themes/coral/source/js/plugins.js b/docs/themes/coral/source/js/plugins.js index 83f14d356..26f7707c3 100644 --- a/docs/themes/coral/source/js/plugins.js +++ b/docs/themes/coral/source/js/plugins.js @@ -1,6 +1,17 @@ /* global lunr */ /* eslint-env browser */ +// Sourced from https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript +function getParameterByName(name, url) { + if (!url) url = window.location.href; + name = name.replace(/[\[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); +} + // Sourced from https://github.com/hexojs/site/blob/8e8ed4901769abbf76263125f82832df76ced58b/themes/navy/source/js/plugins.js. (function() { 'use strict'; @@ -60,6 +71,12 @@ updateCount(elements.length); } + var searchParam = getParameterByName('q'); + if (searchParam && searchParam.length > 0) { + $input.value = searchParam; + search(searchParam); + } + $input.addEventListener('input', function() { var value = this.value; diff --git a/errors.js b/errors.js index 37f42f4ff..39cb6b8ff 100644 --- a/errors.js +++ b/errors.js @@ -161,6 +161,24 @@ class ErrAssetCommentingClosed extends TalkError { } } +// ErrCommentingDisabled is returned when a comment or action is attempted while +// commenting has been disabled site-wide. +class ErrCommentingDisabled extends TalkError { + constructor(message = null) { + super( + 'asset commenting is closed', + { + status: 400, + translation_key: 'COMMENTING_DISABLED', + }, + { + // Include the closedMessage in the metadata piece of the error. + message, + } + ); + } +} + /** * ErrAuthentication is returned when there is an error authenticating and the * message is provided. @@ -371,6 +389,14 @@ class ErrParentDoesNotVisible extends TalkError { } } +class ErrPasswordIncorrect extends TalkError { + constructor() { + super('Your current password was entered incorrectly', { + translation_key: 'PASSWORD_INCORRECT', + }); + } +} + module.exports = { TalkError, ErrAlreadyExists, @@ -379,6 +405,7 @@ module.exports = { ErrAuthentication, ErrCannotIgnoreStaff, ErrCommentTooShort, + ErrCommentingDisabled, ErrContainsProfanity, ErrEditWindowHasEnded, ErrEmailAlreadyVerified, @@ -395,6 +422,7 @@ module.exports = { ErrNotFound, ErrNotVerified, ErrParentDoesNotVisible, + ErrPasswordIncorrect, ErrPasswordResetToken, ErrPasswordTooShort, ErrPermissionUpdateUsername, diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index 40305760c..868c8af68 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -71,7 +71,7 @@ const findOrCreateAssetByURL = async (ctx, url) => { // Check for whitelisting + get the settings at the same time. const [whitelisted, settings] = await Promise.all([ DomainList.urlCheck(url), - Settings.load('autoCloseStream closedTimeout'), + Settings.select('autoCloseStream', 'closedTimeout'), ]); // If the domain wasn't whitelisted, then we shouldn't create this asset! diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 37d40a219..b42a636bc 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -94,6 +94,7 @@ const getCommentCountByQuery = (ctx, options) => { author_id, tags, action_type, + excludeDeleted, } = options; // If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs @@ -120,6 +121,12 @@ const getCommentCountByQuery = (ctx, options) => { query.merge({ author_id }); } + if (excludeDeleted) { + // The null query matches documents that either contain the `deleted_at` + // field whose value is null or that do not contain the `deleted_at` field. + query.merge({ deleted_at: null }); + } + if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) { query.merge({ [`action_counts.${sc(action_type.toLowerCase())}`]: { @@ -328,11 +335,12 @@ const getCommentsByQuery = async ( sortOrder, sortBy, excludeIgnored, + excludeDeleted, tags, action_type, } ) => { - let comments = CommentModel.find(); + const query = CommentModel.find(); // Enforce that the limit must be gte 0 if this option is not true. if (!ALLOW_NO_LIMIT_QUERIES && limit < 0) { @@ -350,11 +358,17 @@ const getCommentsByQuery = async ( } if (statuses) { - comments = comments.where({ status: { $in: statuses } }); + query.merge({ status: { $in: statuses } }); + } + + if (excludeDeleted) { + // The null query matches documents that either contain the `deleted_at` + // field whose value is null or that do not contain the `deleted_at` field. + query.merge({ deleted_at: null }); } if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) { - comments = comments.where({ + query.merge({ [`action_counts.${sc(action_type.toLowerCase())}`]: { $gt: 0, }, @@ -362,7 +376,7 @@ const getCommentsByQuery = async ( } if (ids) { - comments = comments.find({ + query.merge({ id: { $in: ids, }, @@ -370,7 +384,7 @@ const getCommentsByQuery = async ( } if (tags) { - comments = comments.find({ + query.merge({ 'tags.tag.name': { $in: tags, }, @@ -383,17 +397,17 @@ const getCommentsByQuery = async ( (ctx.user.can(SEARCH_OTHERS_COMMENTS) || ctx.user.id === author_id) && author_id != null ) { - comments = comments.where({ author_id }); + query.merge({ author_id }); } if (asset_id) { - comments = comments.where({ asset_id }); + query.merge({ asset_id }); } // We perform the undefined check because, null, is a valid state for the // search to be with, which indicates that it is at depth 0. if (parent_id !== undefined) { - comments = comments.where({ parent_id }); + query.merge({ parent_id }); } if ( @@ -402,12 +416,12 @@ const getCommentsByQuery = async ( ctx.user.ignoresUsers && ctx.user.ignoresUsers.length > 0 ) { - comments = comments.where({ + query.merge({ author_id: { $nin: ctx.user.ignoresUsers }, }); } - return executeWithSort(ctx, comments, { cursor, sortOrder, sortBy, limit }); + return executeWithSort(ctx, query, { cursor, sortOrder, sortBy, limit }); }; /** diff --git a/graph/loaders/settings.js b/graph/loaders/settings.js index fa0a17459..7010d7533 100644 --- a/graph/loaders/settings.js +++ b/graph/loaders/settings.js @@ -1,23 +1,72 @@ -const SettingsService = require('../../services/settings'); +const Settings = require('../../services/settings'); const DataLoader = require('dataloader'); +const { zipObject } = require('lodash'); /** - * Creates a set of loaders based on a GraphQL context. - * @param {Object} context the context of the GraphQL request - * @return {Object} object of loaders + * SettingsLoader manages loading specific fields only of the Settings object. */ -module.exports = () => { - const loader = new DataLoader(selections => - Promise.all( - selections.map(fields => { - return SettingsService.retrieve(fields); - }) - ) - ); +class SettingsLoader { + constructor() { + this._loader = new DataLoader(this._batchLoadFn.bind(this)); + this._cache = null; + } - return { - Settings: { - load: (fields = false) => loader.load(fields), - }, - }; -}; + async _batchLoadFn(fields) { + // Load a settings object with all the requested fields, unless we have the + // entire object cached, in which case we'll return the whole cache. + const obj = this._cache + ? await this._cache + : await Settings.select(...fields); + + // Return the specific fields for each of the fields that were loaded. + return fields.map(field => obj[field]); + } + + /** + * load will return the entire Settings object with all fields. + */ + load() { + if (this._cache) { + // Return the cached settings promise. + return this._cache; + } + + // Create a promise that will return the settings object. + const promise = Settings.retrieve(); + + // Set this as the cached value. + this._cache = promise; + + // Return the promised settings. + return promise; + } + + /** + * select will return a promise which resolves to the Settings object that + * contains the requested fields only. + * + * @param {Array} fields the fields from Settings we want to load. + */ + async select(...fields) { + // Load all the values for the specific fields. + const values = await this._loader.loadMany(fields); + + // Zip up the fields and values to create an object to return and return the + // assembled Settings object. + return zipObject(fields, values); + } + + /** + * get, like select, will retrieve the settings, but get will only return a + * single setting. + * + * @param {String} field the field to get + */ + async get(field) { + const value = await this._loader.load(field); + + return value; + } +} + +module.exports = () => ({ Settings: new SettingsLoader() }); diff --git a/graph/mutators/action.js b/graph/mutators/action.js index d759449b0..5557e1a9b 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -14,8 +14,15 @@ const getActionItem = async (ctx, { item_id, item_type }) => { const { loaders: { Comments, Users } } = ctx; switch (item_type) { - case 'COMMENTS': - return Comments.get.load(item_id); + case 'COMMENTS': { + // Get a comment by ID, unless the comment is deleted, then return null. + const comment = await Comments.get.load(item_id); + if (comment.deleted_at) { + return null; + } + + return comment; + } case 'USERS': return Users.getByID.load(item_id); default: diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 04fdcd366..7422f2290 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -1,4 +1,8 @@ -const { ErrNotFound, ErrNotAuthorized } = require('../../errors'); +const { + ErrNotFound, + ErrNotAuthorized, + ErrPasswordIncorrect, +} = require('../../errors'); const Users = require('../../services/users'); const migrationHelpers = require('../../services/migration/helpers'); const { @@ -8,7 +12,7 @@ const { SET_USER_BAN_STATUS, SET_USER_SUSPENSION_STATUS, UPDATE_USER_ROLES, - DELETE_USER, + DELETE_OTHER_USER, CHANGE_PASSWORD, } = require('../../perms/constants'); @@ -66,15 +70,25 @@ const setRole = (ctx, id, role) => { /** * transforms a specific action to a removal action on the target model. */ -const actionDecrTransformer = ({ item_id, action_type, group_id }) => ({ - query: { id: item_id }, - update: { +const actionDecrTransformer = ({ item_id, action_type, group_id }) => { + const update = { $inc: { [`action_counts.${action_type.toLowerCase()}`]: -1, - [`action_counts.${action_type.toLowerCase()}_${group_id.toLowerCase()}`]: -1, }, - }, -}); + }; + + if (group_id) { + // If the action had a groupID, also decrement that key. + update.$inc[ + `action_counts.${action_type.toLowerCase()}_${group_id.toLowerCase()}` + ] = -1; + } + + return { + query: { id: item_id }, + update, + }; +}; // delUser will delete a given user with the specified id. const delUser = async (ctx, id) => { @@ -93,7 +107,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 +126,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(); }; @@ -154,17 +184,17 @@ const changeUserPassword = async (ctx, oldPassword, newPassword) => { // Verify the old password. const validPassword = await user.verifyPassword(oldPassword); if (!validPassword) { - throw new ErrNotAuthorized(); + throw new ErrPasswordIncorrect(); } // Change the users password now. await Users.changePassword(user.id, newPassword); // Get some context for the email to be sent. - const { organizationName, organizationContactEmail } = await Settings.load([ + const { organizationName, organizationContactEmail } = await Settings.select( 'organizationName', - 'organizationContactEmail', - ]); + 'organizationContactEmail' + ); // Send the password change email. await Users.sendEmail(user, { @@ -225,7 +255,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/asset.js b/graph/resolvers/asset.js index 6bdf4d19e..3ed5305b7 100644 --- a/graph/resolvers/asset.js +++ b/graph/resolvers/asset.js @@ -1,4 +1,4 @@ -const { decorateWithTags } = require('./util'); +const { decorateWithTags, getRequestedFields } = require('./util'); const Asset = { async comment({ id }, { id: commentId }, { loaders: { Comments } }) { @@ -64,14 +64,17 @@ const Asset = { return Comments.countByAssetID.load(id); }, - async settings({ settings = null }, _, { loaders: { Settings } }) { + async settings({ settings = null }, _, { loaders: { Settings } }, info) { + // Get the fields we want from the settings. + const fields = getRequestedFields(info); + // Load the global settings, and merge them into the asset specific settings // if we have some. - let globalSettings = await Settings.load(); + let globalSettings = await Settings.select(...fields); if (settings !== null) { - settings = Object.assign({}, globalSettings.toObject(), settings); + settings = Object.assign({}, globalSettings, settings); } else { - settings = globalSettings.toObject(); + settings = globalSettings; } return settings; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 064835f8f..acd55cb1a 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! @@ -54,11 +57,15 @@ const Comment = { asset({ asset_id }, _, { loaders: { Assets } }) { return Assets.getByID.load(asset_id); }, - async editing(comment, _, { loaders: { Settings } }) { - const settings = await Settings.load(); - const editableUntil = new Date( - Number(new Date(comment.created_at)) + settings.editCommentWindowLength + editing: async (comment, _, { loaders: { Settings } }) => { + const editCommentWindowLength = await Settings.get( + 'editCommentWindowLength' ); + + const editableUntil = new Date( + Number(new Date(comment.created_at)) + editCommentWindowLength + ); + return { edited: comment.edited, editableUntil: editableUntil, @@ -83,6 +90,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/resolvers/root_query.js b/graph/resolvers/root_query.js index 23fdef288..53296a2ca 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -1,4 +1,4 @@ -const { decorateWithPermissionCheck } = require('./util'); +const { decorateWithPermissionCheck, getRequestedFields } = require('./util'); const { SEARCH_ASSETS, SEARCH_OTHERS_COMMENTS, @@ -16,8 +16,12 @@ const RootQuery = { return Assets.getByURL(query.url); }, - settings(_, args, { loaders: { Settings } }) { - return Settings.load(); + settings(_, args, { loaders: { Settings } }, info) { + // Get the fields we want from the settings. + const fields = getRequestedFields(info); + + // Load only the requested fields. + return Settings.select(...fields); }, // This endpoint is used for loading moderation queues, so hide it in the diff --git a/graph/resolvers/util.js b/graph/resolvers/util.js index cec2a71dd..a1a745ab2 100644 --- a/graph/resolvers/util.js +++ b/graph/resolvers/util.js @@ -2,7 +2,8 @@ const { ADD_COMMENT_TAG, SEARCH_OTHER_USERS, } = require('../../perms/constants'); -const { property, isBoolean } = require('lodash'); +const { property, isBoolean, pull } = require('lodash'); +const graphqlFields = require('graphql-fields'); /** * getResolver will get the resolver from the typeResolver or apply the default @@ -207,7 +208,11 @@ const decorateWithTags = ( }; }; +const getRequestedFields = info => + pull(Object.keys(graphqlFields(info)), '__typename'); + module.exports = { + getRequestedFields, decorateUserField, decorateWithTags, decorateWithPermissionCheck, diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 03b199e0e..07947af6e 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -418,6 +418,9 @@ input CommentsQuery { # Exclude comments ignored by the requesting user excludeIgnored: Boolean + + # excludeDeleted when true will exclude deleted comments from the response. + excludeDeleted: Boolean = false } input RepliesQuery { @@ -434,6 +437,9 @@ input RepliesQuery { # Exclude comments ignored by the requesting user excludeIgnored: Boolean + + # excludeDeleted when true will exclude deleted comments from the response. + excludeDeleted: Boolean = false } # CommentCountQuery allows the ability to query comment counts by specific @@ -463,6 +469,9 @@ input CommentCountQuery { # Filter by a specific tag name. tags: [String!] + + # excludeDeleted when true will exclude deleted comments from the count. + excludeDeleted: Boolean = false } # UserCountQuery allows the ability to query user counts by specific @@ -503,7 +512,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. @@ -519,7 +528,7 @@ type Comment { replies(query: RepliesQuery = {}): CommentConnection! # replyCount is the number of replies with a depth of 1. Only direct replies - # to this comment are counted. + # to this comment are counted. Deleted comments are included in this count. replyCount: Int # Actions completed on the parent. Requires the `ADMIN` role. @@ -537,6 +546,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! @@ -825,6 +837,13 @@ type Settings { # closed. closedMessage: String + # disableCommenting will disable commenting site-wide. + disableCommenting: Boolean + + # disableCommentingMessage will be shown above the comment stream while + # commenting is disabled site-wide. + disableCommentingMessage: String + # editCommentWindowLength is the length of time (in milliseconds) after a # comment is posted that it can still be edited by the author. editCommentWindowLength: Int @@ -1288,6 +1307,13 @@ input UpdateSettingsInput { # closed. closedMessage: String + # disableCommenting will disable commenting site-wide. + disableCommenting: Boolean + + # disableCommentingMessage will be shown above the comment stream while + # commenting is disabled site-wide. + disableCommentingMessage: String + # charCountEnable is true when the character count restriction is enabled. charCountEnable: Boolean diff --git a/locales/de.yml b/locales/de.yml index d2b247fbc..15404856c 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -121,6 +121,8 @@ de: custom_css_url_desc: "URL eines CSS-Stylesheets zum Überschreiben des Standard-Designs" days: Tage description: "Als Administrator können Sie die Einstellungen für den Kommentarbereich dieses Artikels anpassen:" + disable_commenting_title: "Kommentieren global deaktivieren" + disable_commenting_desc: "Verfassen Sie eine Nachricht, die angezeigt wird, solange das Kommentieren deaktiviert ist." domain_list_text: "Geben Sie Domains an, für die diese Talk-Instanz freigegeben werden soll, z.B. für lokale Test- oder Produktionsumgebungen (Bsp.: localhost:3000 staging.domain.com domain.com)." domain_list_title: "Zugelassene Domains" edit_comment_timeframe_heading: "Zeitlimit zur Bearbeitung von Kommentaren" @@ -223,6 +225,7 @@ de: LOGIN_MAXIMUM_EXCEEDED: "Sie haben zu häufig erfolglos versucht, sich anzumelden. Bitte warten Sie." PASSWORD_REQUIRED: "Passwort ist erforderlich" COMMENTING_CLOSED: "Kommentarbereich ist bereits geschlossen" + COMMENTING_DISABLED: "Die Kommentarfunktion ist derzeit abgeschaltet" NOT_FOUND: "Ressource nicht gefunden" ALREADY_EXISTS: "Ressource existiert bereits" INVALID_ASSET_URL: "Asset-URL ist ungültig" diff --git a/locales/en.yml b/locales/en.yml index 8ecb6c13f..8eecc40b3 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -123,7 +123,9 @@ en: custom_css_url: "Custom CSS URL" custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external." days: Days - description: "As an admin, you can customize the settings for the comment stream for this story:" + description: "Change the comment settings on this story." + disable_commenting_title: "Deactivate commenting site-wide" + disable_commenting_desc: "Write a message that will be displayed while commenting is deactivated." domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)." domain_list_title: "Permitted Domains" edit_info: "Edit Info" @@ -162,9 +164,9 @@ en: suspect_word_title: "Suspect words list" suspect_word_text: "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list." tech_settings: "Tech Settings" - organization_information: "Organization information" + organization_information: "Organization Information" organization_info_copy: "We use this information in email notifications generated by Talk. This connects the messages to your organization, and provides a way for users to contact you if they have an issue with their account." - organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpuse. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked." + organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpose. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked." organization_details: "Organization Details" organization_name: "Organization Name" organization_contact_email: "Organization Contact Email" @@ -218,12 +220,14 @@ en: we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email." if_you_did: "If you did," please_click: "please click here to reset password" + subject: "Password reset" password_change: subject: "{0} password change" body: "The password on your account has been changed.\n\nIf you did not request this change, please contact us at {0}." embedlink: copy: "Copy to Clipboard" error: + PASSWORD_INCORRECT: "Your current password was entered incorrectly" COMMENT_PARENT_NOT_VISIBLE: "The comment that you're replying to has been removed or doesn't exist." EMAIL_VERIFICATION_TOKEN_INVALID: "Email verification token is invalid." EMAIL_ALREADY_VERIFIED: "Email address already verified." @@ -236,7 +240,7 @@ en: RATE_LIMIT_EXCEEDED: "Rate limit exceeded" USERNAME_IN_USE: "Username already in use" USERNAME_REQUIRED: "Must input a username" - EMAIL_NOT_VERIFIED: "E-mail address not verified" + EMAIL_NOT_VERIFIED: "Email address not verified" EDIT_WINDOW_ENDED: "You can no longer edit this comment. The time window to do so has expired." EDIT_USERNAME_NOT_AUTHORIZED: "You do not have permission to update your username." SAME_USERNAME_PROVIDED: "You must submit a different username." @@ -245,15 +249,18 @@ en: LOGIN_MAXIMUM_EXCEEDED: "You have made too many unsuccessful password attempts. Please wait." PASSWORD_REQUIRED: "Must input a password" COMMENTING_CLOSED: "Commenting is already closed" + COMMENTING_DISABLED: "Commenting is currently disabled on this site" NOT_FOUND: "Resource not found" ALREADY_EXISTS: "Resource already exists" INVALID_ASSET_URL: "Assert URL is invalid" CANNOT_IGNORE_STAFF: "Cannot ignore Staff members." - email: "Not a valid E-Mail" + 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: "E-mail address {0} not verified." - email_password: "E-mail and/or password combination incorrect." + email_not_verified: "Email address {0} not verified." + email_password: "Email and/or password combination incorrect." organization_name: "Organization name must only contain letters or numbers." organization_contact_email: "Organization email is not valid." password: "Password must be at least 8 characters" @@ -270,6 +277,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 commenter has deleted their account." comment_is_hidden: "This comment is not available." comments: comments configure_stream: "Configure" @@ -441,7 +449,7 @@ en: title_reject: "We noticed you rejected a username" suspend_user: "Suspend User" yes_suspend: "Yes suspend" - email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns." + email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please email us if you have any questions or concerns." write_message: "Write a message" send: Send thank_you: "We value your safety and feedback. A moderator will review your report." diff --git a/locales/es.yml b/locales/es.yml index 0857444ea..94235e8ba 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -16,26 +16,27 @@ es: send: "Enviar" notify_ban_headline: "Notificar al usuario de la suspensión" notify_ban_description: "Esto notificará al usuario por email que fueron suspendidos de la comunidad" - email_message_ban: "Dear {0},\n\nSomeone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, like or report comments. if you think this has been done in error, please contact our community team." + email_message_ban: "Estimado/a {0},\n\nUsted o alguien con acceso a su cuenta a violado los lineamientos de nuestra comunidad. Como consecuencia de esto, su cuenta fue bloqueada. No podrá realizar ni reportar más comentarios. Si usted piensa que esto ha sido un error, por favor contáctese con nosotros." bio_offensive: "Esta biografia es ofensiva" cancel: Cancelar confirm_email: - click_to_confirm: "Click below to confirm your email address" - confirm: "Confirm" + click_to_confirm: "Haga click debajo para confirmar su dirección de email" + confirm: "Confirmar" password_reset: - set_new_password: "Change Your Password" - new_password: "New Password" - new_password_help: "Password must be at least 8 characters" - confirm_new_password: "Confirm New Password" - change_password: "Change Password" - characters_remaining: "carácteres restantes" + mail_sent: 'Si tiene una cuenta registrada, un enlace para resetear su clave ha sido enviado a esa dirección de correo.' + set_new_password: "Cambie su contraseña" + new_password: "Nueva contraseña" + new_password_help: "La contraseña debe tener al menos 8 caracteres" + confirm_new_password: "Confirme su nueva contraseña" + change_password: "Cambio de contraseña" + characters_remaining: "caracteres restantes" comment: anon: Anónimo - undo_reject: "Undo" + undo_reject: "Deshacer" ban_user: "Usuario Suspendido" comment: "Publicar un comentario" edited: Editado - flagged: reportado + flagged: Reportado view_context: "Ver contexto" comment_box: post: "Publicar" @@ -62,16 +63,16 @@ es: reactions: 'reacciones' story: 'Artículo' flagged_usernames: - notify_approved: '{0} approved username {1}' - notify_rejected: '{0} rejected username {1}' - notify_flagged: '{0} reported username {1}' - notify_changed: 'user {0} changed their username to {1}' + notify_approved: '{0} ha aprobado el nombre de usuario {1}' + notify_rejected: '{0} ha rechazado el nombre de usuario {1}' + notify_flagged: '{0} ha reportado el nombre de usuario {1}' + notify_changed: 'El usuario {0} ha modificado su nombre de usuario por {1}' community: account_creation_date: "Fecha de creación de la cuenta" active: Activa admin: Administrator - ads_marketing: "This looks like an ad/marketing" - are_you_sure: "¿Estas segura que quieres suspender a {0}?" + ads_marketing: "Esto parece ser un ad/marketing" + are_you_sure: "¿Estás segura que quieres suspender a {0}?" ban_user: "¿Quieres suspender al Usuario?" banned: Suspendido banned_user: "Usuario Suspendido" @@ -120,7 +121,7 @@ es: custom_css_url: "URL CSS a medida" custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa." days: Días - description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo" + description: "Modificar la configuración de los comentarios en este artículo." domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)." domain_list_title: "Dominios Permitidos" edit_info: "Editar Información" @@ -201,10 +202,10 @@ es: minutes_plural: "minutos" email: suspended: - subject: "Your account has been suspended" + subject: "Su cuenta ha sido suspendida" banned: - subject: "Your account has been banned" - body: "In accordance with The Coral Project’s community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community." + subject: "Su cuenta ha sido bloqueada" + body: "De acuerdo con las guías de comunidad de The Coral Project, su cuenta a sido bloqueada. No podrá hacer comentarios, reportar o entrar en contacto con nuestra comunidad." confirm: has_been_requested: "Un correo de confirmación ha sido pedido para la siguiente cuenta:" to_confirm: "Para confirmar la cuenta, por favor visitar el siguiente enlace:" @@ -215,12 +216,13 @@ es: we_received_a_request: "Recibimos un pedido para resetear su contraseña. Si no ha pedido ese cambio, por favor ignorar el correo. " if_you_did: "Si lo hizo," please_click: "por favor cliquea aqui para resetear la contraseña." + subject: "Recuperar contraseña" embedlink: copy: "Copiar al portapapeles" error: COMMENT_PARENT_NOT_VISIBLE: "El comentario a la que estás contestando ha sido eliminado o no existe." - EMAIL_VERIFICATION_TOKEN_INVALID: "Email verification token is invalid." - PASSWORD_RESET_TOKEN_INVALID: "Your password reset link is invalid." + EMAIL_VERIFICATION_TOKEN_INVALID: "El token de verificación de su cuenta de correo es inválido." + PASSWORD_RESET_TOKEN_INVALID: "El enlace para resetear su clave es inválido." COMMENT_TOO_SHORT: "Tu comentario debe tener algo escrito" NOT_AUTHORIZED: "Acción no autorizada." NO_SPECIAL_CHARACTERS: "Los nombres pueden contener letras números y _" @@ -229,19 +231,20 @@ es: RATE_LIMIT_EXCEEDED: "Rate limit exceeded" USERNAME_IN_USE: "Este nombre ya está siendo usado." USERNAME_REQUIRED: "Debe ingresar un nombre" - EMAIL_NOT_VERIFIED: "E-mail address not verified" - EDIT_WINDOW_ENDED:: "No puedes editar este comentario. El tiempo de edición ha expirado." + EMAIL_NOT_VERIFIED: "La cuenta de email no ha sido verificada." + EDIT_WINDOW_ENDED: "No puedes editar este comentario. El tiempo de edición ha expirado." EDIT_USERNAME_NOT_AUTHORIZED: "No tiene permiso para editar el nombre de usuario." - SAME_USERNAME_PROVIDED: "You must submit a different username." + SAME_USERNAME_PROVIDED: "Debe proporcinar un nombre de usuario diferente." EMAIL_IN_USE: "Este correo se encuentra en uso" EMAIL_REQUIRED: "Se requiere un correo" LOGIN_MAXIMUM_EXCEEDED: "Ha realizado demasiados intentos fallidos de usar la contraseña. Por favor espere." PASSWORD_REQUIRED: "Debe ingresar la contraseña" COMMENTING_CLOSED: "Los comentarios ya estan cerrados" NOT_FOUND: "Recurso no encontrado" - ALREADY_EXISTS: "Resource already exists" + ALREADY_EXISTS: "El recurso ya existe" INVALID_ASSET_URL: "La URL del articulo no es valida" - CANNOT_IGNORE_STAFF: "Cannot ignore Staff members." + CANNOT_IGNORE_STAFF: "No puede ignorar a miembros del Staff." + INCORRECT_PASSWORD: "Contraseña Incorrecta" email: "No es un correo válido" confirm_password: "Las contraseñas no coinciden. Inténtelo nuevamente" network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo." @@ -252,8 +255,8 @@ es: password: "La contraseña debe tener por lo menos 8 caracteres" username: "Los nombres pueden contener letras números y _" required_field: "Este campo es requerido" - unexpected: "Lo siento. Ha habido un error no previsto." - temporarily_suspended: "Your account is currently suspended. It will be reactivated {0}. Please contact us if you have any questions." + unexpected: "Lo siento. Ha ocurrido un error no previsto." + temporarily_suspended: "Su cuenta fue suspendida. Será reactivada {0}. Si tiene preguntas, por favor contáctese con nosotros." flag_comment: "Reportar este comentario" flag_reason: "Razón por la que hacer este reporte (Opcional)" flag_username: "Reportar el nombre de usuario" @@ -273,7 +276,7 @@ es: label: "Nuevo Nombre" msg: "Tu cuenta está suspendida porque tu nombre de usuario ha sido considerado no apropiado para el espacio. Para recuperar la cuenta, por favor ingresar un nuevo nombre de usuario. Contáctanos si tienes alguna pregunta." changed_name: - msg: "Your username change is under review by our moderation team." + msg: "El cambio en su nombre de usuario está siendo revisado por nuestro equipo de moderación." my_comments: "Mis Comentarios" my_profile: "Mi perfil" new_count: "Ver {0} más {1} " @@ -304,9 +307,9 @@ es: comment_spam: "Contiene spam" comment_noagree: "No está de acuerdo" comment_other: "Otra razón" - suspect_word: "Suspect Word" - banned_word: "Banned Word" - body_count: "Body exceeds max length" + suspect_word: "Palabra sospechosa" + banned_word: "Palabra prohibida" + body_count: "El texto exede el límite permitido" trust: "Trust" links: "Link" modqueue: @@ -314,14 +317,14 @@ es: actions: Acciones all: todos all_streams: "Todos los Hilos" - notify_edited: '{0} edited comment "{1}"' - notify_accepted: '{0} accepted comment "{1}"' - notify_rejected: '{0} rejected comment "{1}"' - notify_flagged: '{0} flagged comment "{1}"' - notify_reset: '{0} reset status of comment "{1}"' + notify_edited: '{0} ha editado el comentario "{1}"' + notify_accepted: '{0} ha aceptado el comentario "{1}"' + notify_rejected: '{0} ha rechazado el comentario "{1}"' + notify_flagged: '{0} ha reportado el comentario "{1}"' + notify_reset: '{0} ha reseteado el status del comentario "{1}"' approve: "Aprobar" approved: "Aprobado" - ban_user: "Ban" + ban_user: "Bloquear" billion: B close: Cerrar empty_queue: "¡No hay más comentarios para moderar! Tiempo para un ☕️" @@ -361,7 +364,7 @@ es: no_agree_comment: "No estoy de acuerdo con este comentario" no_like_bio: "No me gusta esta biografia" no_like_username: "No me gusta este nombre de usuario" - already_flagged_username: "You have already flagged this username." + already_flagged_username: "Usted ya reportó a este usuario." other: Otro permalink: Compartir personal_info: "Este comentario muestra información personal" @@ -442,35 +445,28 @@ es: username: 'Usuario' username_needs_approval: 'El usuario necesita aprovación' username_rejected: 'Usuario rechazado' - remove_suspension: "Quitar suspensión" suspend: "Suspender usuario" - remove_ban: "Quitar ban" - ban: "Banear usuario" - member_since: "Miembro desde" - email: "Email" - total_comments: "Comentarios totales" + email: "Correo electrónico" reject_rate: "Promedio de rechazo" - reports: "Reportes" all: "Todos" rejected: "Rechazado" - account_history: "Account History" - account_history: - total_comments: "Total Comments" - reject_rate: "Reject Rate" - reports: "Reports" - all: "All" - rejected: "Rejected" - user_history: "User History" + remove_suspension: "Cancelar suspensión" + remove_ban: "Cancelar bloqueo" + ban: "Bloquear usuario" + member_since: "Miembro desde" + total_comments: "Comentarios totales" + reports: "Reportes" + user_history: "Historial del usuario" user_history: - user_banned: "User banned" - ban_removed: "Ban removed" - username_status: "Username {0}" - suspended: "Suspended, {0}" - suspension_removed: "Suspension removed" - system: "System" - date: "Date" - action: "Action" - taken_by: "Taken By" + user_banned: "Usuario bloqueado" + ban_removed: "Bloqueo cancelado" + username_status: "Nombre de usuario {0}" + suspended: "Suspendido, {0}" + suspension_removed: "Suspensión cancelada" + system: "Sistema" + date: "Fecha" + action: "Acción" + taken_by: "Está en uso" user_impersonating: "Este usuario suplanta a alguien" user_no_comment: "No has dejado aún ningún comentario. ¡Únete a la conversación!" username_offensive: "Este nombre de usuario es ofensivo" @@ -499,5 +495,5 @@ es: launch: "Iniciar Talk" close: "Cerrar este instalador" admin_sidebar: - view_options: "View Options" - sort_comments: "Sort Comments" + view_options: "Ver opciones" + sort_comments: "Ordenar comentarios" diff --git a/locales/fr.yml b/locales/fr.yml index 2230549e7..1e9e0e496 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -121,7 +121,7 @@ fr: custom_css_url: "URL CSS personnalisée" custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe." days: Journées - description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément." + description: "Personnaliser les paramètres du fil de commentaires pour cet élément." domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)." domain_list_title: "Domaines autorisés" edit_comment_timeframe_heading: "Modifier l'horodatage des commentaires" diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index c236bdb4c..4b7b5c3e3 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -120,7 +120,7 @@ pt_BR: custom_css_url: "URL para CSS customizado" custom_css_url_desc: "URL de uma folha de estilo CSS para substituir os estilos padrão dos comentários embutidos. Pode ser interno ou externo." days: Dias - description: "Como administrador, você pode personalizar as configurações da lista de comentários para esta história:" + description: "Personalizar as configurações da lista de comentários para esta história." domain_list_text: "Insira os domínios que você gostaria de permitir para o Talk, como seus ambientes de desenvolvimento, teste ou produção (ej. localhost:3000 staging.domain.com domain.com)." domain_list_title: "Domínios permitidos" edit_comment_timeframe_heading: "Período de tempo para editar comentários" diff --git a/middleware/staticTemplate.js b/middleware/staticTemplate.js index 0dc05a7be..d8137693e 100644 --- a/middleware/staticTemplate.js +++ b/middleware/staticTemplate.js @@ -94,9 +94,13 @@ const createResolveFactory = (() => { module.exports = async (req, res, next) => { try { - // Attach the custom css url. - const { customCssUrl } = await SettingsService.retrieve('customCssUrl'); + // Attach the custom css url and organization name. + const { customCssUrl, organizationName } = await SettingsService.select( + 'customCssUrl', + 'organizationName' + ); res.locals.customCssUrl = customCssUrl; + res.locals.organizationName = organizationName; } catch (err) { console.warn(err); } diff --git a/middleware/trace.js b/middleware/trace.js index 24cbf38f4..6ac6f4b2c 100644 --- a/middleware/trace.js +++ b/middleware/trace.js @@ -3,5 +3,9 @@ const uuid = require('uuid/v1'); // Trace middleware attaches a request id to each incoming request. module.exports = (req, res, next) => { req.id = uuid(); + + // Add the context ID to the request as an HTTP header. + res.set('X-Talk-Trace-ID', req.id); + next(); }; diff --git a/models/schema/action.js b/models/schema/action.js index 1df7bd793..d2047faac 100644 --- a/models/schema/action.js +++ b/models/schema/action.js @@ -9,7 +9,8 @@ const Action = new Schema( id: { type: String, default: uuid.v4, - unique: true, + unique: 1, + index: 1, }, action_type: { type: String, @@ -19,7 +20,10 @@ const Action = new Schema( type: String, enum: ITEM_TYPES, }, - item_id: String, + item_id: { + type: String, + index: 1, + }, user_id: String, // The element that summaries will additionally group on in addtion to their action_type, item_type, and @@ -37,15 +41,4 @@ const Action = new Schema( } ); -// Create an index on the `item_id` field so that queries looking for -// actions based on the item id can resolve faster. -Action.index( - { - item_id: 1, - }, - { - background: true, - } -); - module.exports = Action; diff --git a/models/schema/asset.js b/models/schema/asset.js index 043bc9a3f..82de92120 100644 --- a/models/schema/asset.js +++ b/models/schema/asset.js @@ -43,8 +43,7 @@ const Asset = new Schema( modified_date: Date, // This object is used exclusively for storing settings that are to override - // the base settings from the base Settings object. This is to be accessed - // always after running `rectifySettings` against it. + // the base settings from the base Settings object. settings: { default: {}, type: Object, diff --git a/models/schema/comment.js b/models/schema/comment.js index d9586e850..7ae51ff41 100644 --- a/models/schema/comment.js +++ b/models/schema/comment.js @@ -55,14 +55,16 @@ const Comment = new Schema( type: String, default: uuid.v4, unique: true, + index: true, }, body: { type: String, - required: [true, 'The body is required.'], - minlength: 2, }, body_history: [BodyHistoryItemSchema], - asset_id: String, + asset_id: { + type: String, + index: true, + }, author_id: String, status_history: [Status], status: { @@ -89,6 +91,11 @@ 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, + }, + // Additional metadata stored on the field. metadata: { default: {}, @@ -106,95 +113,67 @@ const Comment = new Schema( } ); -// Add the indexes for the id of the comment. Comment.index( { - id: 1, - }, - { - unique: true, - background: false, - } -); - -Comment.index( - { - status: 1, - created_at: 1, - }, - { - background: true, - } -); - -Comment.index( - { - status: 1, - created_at: 1, - asset_id: 1, - }, - { - background: true, - } -); - -// Create a sparse index to search across. -Comment.index( - { - created_at: 1, - 'action_counts.flag': 1, - status: 1, - }, - { - background: true, - sparse: true, - } -); - -// Create a sparse index to search across. -Comment.index( - { - 'action_counts.flag': 1, - status: 1, - }, - { - background: true, - sparse: true, - } -); - -// Add an index that is optimized for finding flagged comments. -Comment.index( - { - asset_id: 1, - created_at: 1, - 'action_counts.flag': 1, - }, - { - background: true, - } -); - -// Add an index for the reply sort. -Comment.index( - { - asset_id: 1, + deleted_at: 1, created_at: -1, - reply_count: -1, }, - { - background: true, - } + { partialFilterExpression: { deleted_at: null } } ); -// Add an index that is optimized for finding a user's comments. Comment.index( { - author_id: 1, + deleted_at: 1, + status: 1, + created_at: -1, + }, + { partialFilterExpression: { deleted_at: null } } +); + +Comment.index({ + asset_id: 1, + created_at: -1, +}); + +Comment.index({ + asset_id: 1, + created_at: 1, +}); + +Comment.index({ + author_id: 1, + created_at: -1, +}); + +Comment.index({ + asset_id: 1, + status: 1, +}); + +Comment.index({ + asset_id: 1, + parent_id: 1, + reply_count: -1, + created_at: -1, +}); + +Comment.index({ + asset_id: 1, + reply_count: -1, + created_at: -1, +}); + +Comment.index( + { + 'action_counts.flag': 1, + status: 1, created_at: -1, }, { - background: true, + partialFilterExpression: { + 'action_counts.flag': { $exists: true, $gt: 0 }, + deleted_at: null, + }, } ); @@ -206,34 +185,10 @@ Comment.index( status: 1, }, { - background: true, - } -); - -// Optimize for tag searches/counts. -Comment.index( - { - 'tags.tag.name': 1, - status: 1, - }, - { - background: true, sparse: true, } ); -// Add an index that is optimized for sorting based on the created_at timestamp -// but also good at locating comments that have a specific asset id. -Comment.index( - { - asset_id: 1, - created_at: 1, - }, - { - background: true, - } -); - Comment.virtual('edited').get(function() { return this.body_history.length > 1; }); diff --git a/models/schema/index.js b/models/schema/index.js index 976b437c0..c33402078 100644 --- a/models/schema/index.js +++ b/models/schema/index.js @@ -1,5 +1,3 @@ -const { CREATE_MONGO_INDEXES } = require('../../config'); - const Action = require('./action'); const Asset = require('./asset'); const Comment = require('./comment'); @@ -7,15 +5,4 @@ const Migration = require('./migration'); const Setting = require('./setting'); const User = require('./user'); -const schema = { Action, Asset, Comment, Migration, Setting, User }; - -// Provide the schema to each of the plugins so that they can add in indexes if -// it is enabled. -if (CREATE_MONGO_INDEXES) { - const plugins = require('../../services/plugins'); - plugins.get('server', 'indexes').map(({ indexes }) => { - indexes(schema); - }); -} - -module.exports = schema; +module.exports = { Action, Asset, Comment, Migration, Setting, User }; diff --git a/models/schema/setting.js b/models/schema/setting.js index af7932910..d68ee7acd 100644 --- a/models/schema/setting.js +++ b/models/schema/setting.js @@ -12,6 +12,8 @@ const Setting = new Schema( id: { type: String, default: '1', + unique: 1, + index: true, }, moderation: { type: String, @@ -66,6 +68,14 @@ const Setting = new Schema( type: String, default: 'Expired', }, + disableCommenting: { + type: Boolean, + default: false, + }, + disableCommentingMessage: { + type: String, + default: '', + }, wordlist: { banned: { type: Array, @@ -125,21 +135,4 @@ const Setting = new Schema( } ); -/** - * Merges two settings objects. - */ -Setting.method('merge', function(src) { - Setting.eachPath(path => { - // Exclude internal fields... - if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) { - return; - } - - // If the source object contains the path, shallow copy it. - if (path in src) { - this[path] = src[path]; - } - }); -}); - module.exports = Setting; diff --git a/models/schema/user.js b/models/schema/user.js index ec9c018cc..a4ccfd185 100644 --- a/models/schema/user.js +++ b/models/schema/user.js @@ -58,6 +58,7 @@ const User = new Schema( default: uuid.v4, unique: true, required: true, + index: true, }, // This is sourced from the social provider or set manually during user setup @@ -107,6 +108,7 @@ const User = new Schema( status: { type: String, enum: USER_STATUS_USERNAME, + index: true, }, // History stores the history of username status changes. @@ -135,6 +137,7 @@ const User = new Schema( type: Boolean, required: true, default: false, + index: true, }, history: [ { @@ -226,41 +229,26 @@ User.index( } ); -User.index( - { - lowercaseUsername: 1, - 'profiles.id': 1, - created_at: -1, - }, - { - background: true, - } -); +User.index({ + lowercaseUsername: 1, + 'profiles.id': 1, + created_at: -1, +}); // This query is executed often, to count the number of flagged accounts with // usernames. -User.index( - { - 'action_counts.flag': 1, - 'status.username.status': 1, - }, - { - background: true, - } -); +User.index({ + 'action_counts.flag': 1, + 'status.username.status': 1, +}); // Sorting users by created at is the default people search. -User.index( - { - created_at: -1, - }, - { - background: true, - } -); +User.index({ + created_at: -1, +}); /** - * returns true if a commenter is staff + * returns true if a commenter is staff. */ User.method('isStaff', function() { return this.role !== 'COMMENTER'; @@ -330,6 +318,9 @@ User.virtual('hasVerifiedEmail').get(function() { }); }); +/** + * system returns true when the user is a system user. + */ User.virtual('system') .get(function() { return this._system; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d6105c594..000000000 --- a/package-lock.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "talk", - "version": "4.3.0", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "exenv": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", - "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=" - }, - "react-side-effect": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.5.tgz", - "integrity": "sha512-Z2ZJE4p/jIfvUpiUMRydEVpQRf2f8GMHczT6qLcARmX7QRb28JDBTpnM2g/i5y/p7ZDEXYGHWg0RbhikE+hJRw==", - "requires": { - "exenv": "1.2.2", - "shallowequal": "1.0.2" - } - }, - "shallowequal": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.0.2.tgz", - "integrity": "sha512-zlVXeVUKvo+HEv1e2KQF/csyeMKx2oHvatQ9l6XjCUj3agvC8XGf6R9HvIPDSmp8FNPvx7b5kaEJTRi7CqxtEw==" - } - } -} diff --git a/package.json b/package.json index c7e9a78c1..97f4e373d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "4.3.2", + "version": "4.4.1", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "private": true, @@ -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", @@ -115,6 +114,7 @@ "graphql-ast-tools": "0.2.3", "graphql-docs": "0.2.0", "graphql-errors": "^2.1.0", + "graphql-fields": "^1.0.2", "graphql-redis-subscriptions": "1.3.0", "graphql-subscriptions": "^0.4.3", "graphql-tag": "^1.2.3", @@ -137,8 +137,8 @@ "keymaster": "^1.6.2", "kue": "0.11.6", "linkify-it": "^2.0.3", - "lodash": "^4.16.6", - "lodash-es": "^4.16.6", + "lodash": "^4.17.10", + "lodash-es": "^4.17.10", "marked": "^0.3.6", "material-design-lite": "^1.2.1", "metascraper": "^3.9.2", @@ -219,6 +219,7 @@ "babel-plugin-dynamic-import-node": "^1.1.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", "browserstack-local": "^1.3.0", + "casual": "^1.5.19", "chai": "^3.5.0", "chai-as-promised": "^6.0.0", "chai-datetime": "^1.5.0", 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 d0841ef99..8133fcd47 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -1,4 +1,5 @@ -const { isString } = require('lodash'); +const { get, isString } = require('lodash'); +const moment = require('moment'); const { check } = require('../utils'); const types = require('../constants'); @@ -12,11 +13,21 @@ module.exports = (user, perm) => { isString(user.password) && user.password.length > 0 ); + case types.CHANGE_USERNAME: return user.status.username.status === 'REJECTED'; - case types.SET_USERNAME: - return user.status.username.status === 'UNSET'; + case types.SET_USERNAME: { + // Only users who have their usernames rejected or those users who + // not changed their usernames within 14 days can change their usernames. + const deadline = moment().subtract(14, 'days'); + return ( + user.status.username.status === 'UNSET' || + get(user, 'status.username.history', []).every(({ created_at }) => + moment(created_at).isBefore(deadline) + ) + ); + } case types.CREATE_COMMENT: case types.CREATE_ACTION: @@ -45,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/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 41ede8445..d7ca5fce9 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -26,5 +26,6 @@ export { withStopIgnoringUser, withSetCommentStatus, withChangePassword, + withChangeUsername, } from 'coral-framework/graphql/mutations'; export { compose } from 'recompose'; 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 da075d76c..f1f25b261 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -2,23 +2,37 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants'); const { ErrNotFound, ErrAlreadyExists } = require('../../../errors'); const pluralize = require('pluralize'); const sc = require('snake-case'); -// const { CREATE_MONGO_INDEXES } = require('../../../config'); +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) { - // // Create the index on the comment model based on the reaction config. - // CommentModel.collection.createIndex( - // { - // created_at: 1, - // [`action_counts.${sc(reaction)}`]: 1, - // }, - // { - // background: true, - // } - // ); - // } + if (CREATE_MONGO_INDEXES) { + // Create the index on the comment model based on the reaction config. + Comment.collection.ensureIndex( + { + asset_id: 1, + [`action_counts.${sc(reaction)}`]: -1, + created_at: -1, + }, + { + background: true, + } + ); + + Comment.collection.ensureIndex( + { + asset_id: 1, + [`action_counts.${sc(reaction)}`]: -1, + created_at: -1, + }, + { + background: true, + } + ); + } const reactionPlural = pluralize(reaction); const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1); @@ -127,17 +141,6 @@ function getReactionConfig(reaction) { return { typeDefs, - indexes: ({ Comment }) => { - Comment.index( - { - created_at: 1, - [`action_counts.${sc(reaction)}`]: 1, - }, - { - background: true, - } - ); - }, context: { Sort: () => ({ Comments: { diff --git a/plugins.default.json b/plugins.default.json index e1011aa87..0fbc6e658 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -1,9 +1,9 @@ { "server": [ - "talk-plugin-auth", "talk-plugin-featured-comments", - "talk-plugin-respect", - "talk-plugin-profile-data" + "talk-plugin-local-auth", + "talk-plugin-profile-data", + "talk-plugin-respect" ], "client": [ "talk-plugin-auth", @@ -11,15 +11,16 @@ "talk-plugin-featured-comments", "talk-plugin-flag-details", "talk-plugin-ignore-user", + "talk-plugin-local-auth", "talk-plugin-member-since", "talk-plugin-moderation-actions", "talk-plugin-permalink", + "talk-plugin-profile-data", "talk-plugin-respect", "talk-plugin-sort-most-replied", "talk-plugin-sort-most-respected", "talk-plugin-sort-newest", "talk-plugin-sort-oldest", - "talk-plugin-viewing-options", - "talk-plugin-profile-data" + "talk-plugin-viewing-options" ] } diff --git a/plugins/talk-plugin-akismet/server/hooks.js b/plugins/talk-plugin-akismet/server/hooks.js index 80a233584..b06557783 100644 --- a/plugins/talk-plugin-akismet/server/hooks.js +++ b/plugins/talk-plugin-akismet/server/hooks.js @@ -71,7 +71,7 @@ module.exports = { permalink: asset.url, comment_type: 'comment', comment_content: input.body, - is_test: true, + is_test: false, }); debug(`comment analyzed as ${spam ? 'being' : 'not being'} spam`); diff --git a/plugins/talk-plugin-auth/README.md b/plugins/talk-plugin-auth/README.md index fd5218365..c09dc218b 100644 --- a/plugins/talk-plugin-auth/README.md +++ b/plugins/talk-plugin-auth/README.md @@ -14,3 +14,9 @@ utilize our internal authentication system. To sync Talk auth with your own auth systems, you can use this plugin as a template. + +## GDPR Compliance + +In order to facilitate compliance with the +[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you +should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines. diff --git a/plugins/talk-plugin-auth/client/index.js b/plugins/talk-plugin-auth/client/index.js index 039f0f936..13abce1d9 100644 --- a/plugins/talk-plugin-auth/client/index.js +++ b/plugins/talk-plugin-auth/client/index.js @@ -4,7 +4,6 @@ import SetUsernameDialog from './stream/containers/SetUsernameDialog'; import translations from './translations.yml'; import Login from './login/containers/Main'; import reducer from './login/reducer'; -import ChangePassword from './profile-settings/containers/ChangePassword'; export default { reducer, @@ -12,6 +11,5 @@ export default { slots: { stream: [UserBox, SignInButton, SetUsernameDialog], login: [Login], - profileSettings: [ChangePassword], }, }; diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.js b/plugins/talk-plugin-auth/client/login/components/SignUp.js index 83f81f81d..96dadcd67 100644 --- a/plugins/talk-plugin-auth/client/login/components/SignUp.js +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.js @@ -103,8 +103,7 @@ class SignUp extends React.Component { /> {passwordError && ( - {' '} - Password must be at least 8 characters.{' '} + {t('talk-plugin-auth.login.password_error')} )} ( -
    -
      {children}
    -
    -); - -Form.propTypes = { - className: PropTypes.string, - children: PropTypes.node, -}; - -export default Form; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css b/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css deleted file mode 100644 index be25a44b9..000000000 --- a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css +++ /dev/null @@ -1,51 +0,0 @@ - -.detailItem { - margin-bottom: 12px; -} - -.detailItemContainer { - display: flex; -} - -.detailItemContent { - min-width: 280px; -} - -.detailLabel { - color: #4C4C4D; - font-size: 1em; - display: block; - margin-bottom: 4px; -} - -.detailValue { - padding: 6px 2px; - border: solid 1px #979797; - display: block; - font-size: 1.1em; - border-radius: 2px; - background-color: #ffffff; - color: #979797; - box-sizing: border-box; - width: 100%; -} - -.detailItemMessage { - flex-grow: 1; - display: flex; - align-items: center; - padding-left: 2px; - padding-top: 16px; - - .warningIcon, .checkIcon { - font-size: 17px; - } -} - -.checkIcon { - color: #00CD73; -} - -.warningIcon { - color: #FA4643; -} diff --git a/plugins/talk-plugin-auth/client/profile-settings/containers/ChangePassword.js b/plugins/talk-plugin-auth/client/profile-settings/containers/ChangePassword.js deleted file mode 100644 index 1322a2940..000000000 --- a/plugins/talk-plugin-auth/client/profile-settings/containers/ChangePassword.js +++ /dev/null @@ -1,12 +0,0 @@ -import { compose } from 'react-apollo'; -import { bindActionCreators } from 'redux'; -import { connect } from 'plugin-api/beta/client/hocs'; -import ChangePassword from '../components/ChangePassword'; -import { notify } from 'coral-framework/actions/notification'; -import { withChangePassword } from 'plugin-api/beta/client/hocs'; - -const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); - -export default compose(connect(null, mapDispatchToProps), withChangePassword)( - ChangePassword -); diff --git a/plugins/talk-plugin-auth/client/translations.yml b/plugins/talk-plugin-auth/client/translations.yml index 915aee1c7..aa7579d74 100644 --- a/plugins/talk-plugin-auth/client/translations.yml +++ b/plugins/talk-plugin-auth/client/translations.yml @@ -58,8 +58,9 @@ da: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" + password_error: "Password must be at least 8 characters." forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" register: "Register" @@ -101,8 +102,9 @@ en: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" + password_error: "Password must be at least 8 characters." forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" register: "Register" @@ -140,6 +142,32 @@ en: cancel: "Cancel" edit: "Edit" changed_password_msg: "Changed Password - Your password has been successfully changed" + change_username: + change_username_note: "Usernames can be changed every 14 days" + save: "Save" + edit_profile: "Edit Profile" + cancel: "Cancel" + confirm_username_change: "Confirm Username Change" + description: "You are attempting to change your username. Your new username will appear on all of your past and future comments." + old_username: "Old Username" + new_username: "New Username" + bottom_note: "Note: You will not be able to change your username again for 14 days" + confirm_changes: "Confirm Changes" + username_does_not_match: "Username does not match" + cant_be_equal: "Your new {0} must be different to your current one" + change_username_attempt: "Username can't be updated. Usernames can be changed every 14 days" + change_email: + confirm_email_change: "Confirm Email Address Change" + description: "You are attempting to change your email address. Your new email address will be used for your login and to receive account notifications." + old_email: "Old Email Address" + new_email: "New Email Address" + enter_password: "Enter Password" + incorrect_password: "Incorrect Password" + confirm_change: "Confirm Change" + cancel: "Cancel" + change_email_msg: "Email Address Changed - Your email address has been successfully changed. This email address will now be used for signing in and email notifications." + changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days." + change_username_attempt: "Username can't be updated. Usernames can only be changed every 14 days." de: talk-plugin-auth: login: @@ -201,6 +229,7 @@ es: or: "O" email: "Dirección de Correo" password: "Contraseña" + password_error: "La contraseña debe tener al menos 8 caracteres." forgot_your_pass: "¿Has olvidado tu contraseña?" need_an_account: "¿Necesitas una cuenta?" register: "Registrar" @@ -240,6 +269,20 @@ es: cancel: "Cancelar" edit: "Editar" changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada" + change_username: + change_username_note: "El usuario puede ser cambiado cada 14 días" + save: "Guardar" + edit_profile: "Editar Perfil" + cancel: "Cancelar" + confirm_username_change: "Confirmar Cambio de Usuario" + description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios." + old_username: "Usuario viejo" + new_username: "Usuario nuevo" + bottom_note: "Nota: No podrás cambiar tu usuario por 14 días" + confirm_changes: "Confirmar Cambios" + username_does_not_match: "El usuario no coincide" + changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días." + change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días." fr: talk-plugin-auth: login: @@ -342,7 +385,7 @@ pt_BR: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" diff --git a/plugins/talk-plugin-facebook-auth/README.md b/plugins/talk-plugin-facebook-auth/README.md index 19d003150..08780adda 100644 --- a/plugins/talk-plugin-facebook-auth/README.md +++ b/plugins/talk-plugin-facebook-auth/README.md @@ -27,4 +27,10 @@ Configuration: or by visiting the [Creating an App ID](https://developers.facebook.com/docs/apps/register) guide. This is only required while the `talk-plugin-facebook-auth` plugin is - enabled. \ No newline at end of file + enabled. + +## GDPR Compliance + +In order to facilitate compliance with the +[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you +should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines. diff --git a/plugins/talk-plugin-facebook-auth/client/components/ErrorMessage.css b/plugins/talk-plugin-facebook-auth/client/components/ErrorMessage.css new file mode 100644 index 000000000..039ffae30 --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/components/ErrorMessage.css @@ -0,0 +1,8 @@ +.errorMsg { + color: #FA4643; + font-size: 0.9em; +} + +.warningIcon { + color: #FA4643; +} diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.js b/plugins/talk-plugin-facebook-auth/client/components/ErrorMessage.js similarity index 100% rename from plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.js rename to plugins/talk-plugin-facebook-auth/client/components/ErrorMessage.js diff --git a/plugins/talk-plugin-facebook-auth/client/components/InputField.css b/plugins/talk-plugin-facebook-auth/client/components/InputField.css new file mode 100644 index 000000000..3442befde --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/components/InputField.css @@ -0,0 +1,80 @@ + +.detailItem { + margin-bottom: 12px; +} + +.detailItemContainer { + display: flex; +} + +.columnDisplay { + flex-direction: column; + + .detailItemMessage { + padding: 4px 0 0; + } +} + +.detailItemContent { + 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; +} + +.detailItemMessage { + flex-grow: 1; + display: flex; + align-items: center; + padding-left: 6px; + padding-top: 16px; + + .warningIcon, .checkIcon { + font-size: 17px; + } +} + +.checkIcon { + color: #00CD73; +} + +.warningIcon { + color: #FA4643; +} diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js b/plugins/talk-plugin-facebook-auth/client/components/InputField.js similarity index 52% rename from plugins/talk-plugin-auth/client/profile-settings/components/InputField.js rename to plugins/talk-plugin-facebook-auth/client/components/InputField.js index ed9c394ee..26937d5b9 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js +++ b/plugins/talk-plugin-facebook-auth/client/components/InputField.js @@ -1,5 +1,6 @@ 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'; @@ -10,51 +11,84 @@ const InputField = ({ type = 'text', name = '', onChange = () => {}, - value = '', showError = true, hasError = false, errorMsg = '', children, + columnDisplay = false, + showSuccess = false, + validationType = '', + icon = '', + value = '', + defaultValue = '', + disabled = false, }) => { + const inputValue = { + ...(value ? { value } : {}), + ...(defaultValue ? { defaultValue } : {}), + }; + return ( -
  • -
    -
    +
    +
    + {label && ( + )} +
    + {icon && }
    {!hasError && + showSuccess && value && } {hasError && showError && {errorMsg}}
    {children} -
  • + ); }; InputField.propTypes = { - id: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, + 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; diff --git a/plugins/talk-plugin-google-auth/README.md b/plugins/talk-plugin-google-auth/README.md index f68b56c32..15eb5cef7 100644 --- a/plugins/talk-plugin-google-auth/README.md +++ b/plugins/talk-plugin-google-auth/README.md @@ -27,3 +27,9 @@ Configuration: - `TALK_GOOGLE_CLIENT_SECRET` (**required**) - The Google OAuth2 client ID for your Google login web app. You can learn more about getting a Google Client ID at the [Google API Console](https://console.developers.google.com/apis/). + +## GDPR Compliance + +In order to facilitate compliance with the +[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you +should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines. diff --git a/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js b/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js index 5aa834034..efd4cd366 100644 --- a/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js +++ b/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js @@ -10,16 +10,22 @@ import { bindActionCreators } from 'redux'; import { closeMenu } from 'plugins/talk-plugin-author-menu/client/actions'; import { notify } from 'plugin-api/beta/client/actions/notification'; import { t } from 'plugin-api/beta/client/services'; +import { getErrorMessages } from 'coral-framework/utils'; class IgnoreUserConfirmationContainer extends React.Component { - ignoreUser = () => { + ignoreUser = async () => { const { ignoreUser, notify, comment, closeMenu } = this.props; - ignoreUser(comment.user.id).then(() => { + + try { + await ignoreUser(comment.user.id); notify( 'success', t('talk-plugin-ignore-user.notify_success', comment.user.username) ); - }); + } catch (err) { + notify('error', getErrorMessages(err)); + } + closeMenu(); }; diff --git a/plugins/talk-plugin-local-auth/README.md b/plugins/talk-plugin-local-auth/README.md new file mode 100644 index 000000000..5fabe0d71 --- /dev/null +++ b/plugins/talk-plugin-local-auth/README.md @@ -0,0 +1,26 @@ +--- +title: talk-plugin-local-auth +permalink: /plugin/talk-plugin-local-auth/ +layout: plugin +plugin: + name: talk-plugin-local-auth + default: true + provides: + - Client + - Server +--- + +This plugin will eventually contain all the local authentication code that is +responsible for creating, resetting, and managing accounts provided locally +through an email and password based login. + +## Features + +- *Email Change*: Allows users to change their existing email address on their account. +- *Local Account Association*: Allows users that have signed up with an external auth strategy (such as Google) the ability to associate a email address and password for login. **Note: Existing users with external authentication will be prompted to setup a local account when they sign in and when new users create an account.** + +## GDPR Compliance + +In order to facilitate compliance with the +[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you +should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines. diff --git a/plugins/talk-plugin-local-auth/client/.eslintrc.json b/plugins/talk-plugin-local-auth/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-local-auth/client/actions.js b/plugins/talk-plugin-local-auth/client/actions.js new file mode 100644 index 000000000..972e1f6fd --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/actions.js @@ -0,0 +1,9 @@ +import * as actions from './constants'; + +export const startAttach = () => ({ + type: actions.START_ATTACH, +}); + +export const finishAttach = () => ({ + type: actions.FINISH_ATTACH, +}); diff --git a/plugins/talk-plugin-local-auth/client/components/AddEmailAddressDialog.css b/plugins/talk-plugin-local-auth/client/components/AddEmailAddressDialog.css new file mode 100644 index 000000000..41b531147 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/AddEmailAddressDialog.css @@ -0,0 +1,82 @@ +.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: 320px; + top: 50%; + transform: translateY(-50%); + padding: 20px; + border-radius: 4px; + font-family: Helvetica,Helvetica Neue,Verdana,sans-serif; + color:#3B4A53; +} + +.title { + font-size: 1.3em; + margin: 15px 0; + text-align: center; +} + +.description { + font-size: 1em; + line-height: 20px; + margin: 0; + margin-bottom: 15px; +} + +.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; + width: 100%; + display: inline-block; + text-align: center; + line-height: 30px; + font-size: 1em; + + &:hover { + background-color: #eaeaea; + cursor: pointer; + } + + &.cancel { + background-color: transparent; + color: #787D80; + } + + &.proceed { + background-color: #3498DB; + color: white; + } + + &.danger { + background-color: #FA4643; + color: white; + } +} \ No newline at end of file diff --git a/plugins/talk-plugin-local-auth/client/components/AddEmailAddressDialog.js b/plugins/talk-plugin-local-auth/client/components/AddEmailAddressDialog.js new file mode 100644 index 000000000..a5bfd7096 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/AddEmailAddressDialog.js @@ -0,0 +1,182 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Dialog } from 'plugin-api/beta/client/components/ui'; +import validate from 'coral-framework/helpers/validate'; +import { getErrorMessages } from 'coral-framework/utils'; +import styles from './AddEmailAddressDialog.css'; +import { t } from 'plugin-api/beta/client/services'; + +import AddEmailContent from './AddEmailContent'; +import VerifyEmailAddress from './VerifyEmailAddress'; +import EmailAddressAdded from './EmailAddressAdded'; + +const initialState = { + step: 0, + showErrors: false, + errors: {}, + formData: { + emailAddress: '', + confirmPassword: '', + confirmEmailAddress: '', + }, +}; + +const validateRequired = v => + v ? '' : t('talk-plugin-local-auth.add_email.required_field'); + +const validateRepeat = (key, msg) => (v, data) => (v !== data[key] ? msg : ''); + +const validateEmail = v => + validateRequired(v) || !validate.email(v) + ? t('talk-plugin-local-auth.add_email.invalid_email_address') + : ''; + +const validatePassword = v => validateRequired(v); + +class AddEmailAddressDialog extends React.Component { + state = initialState; + + fields = { + emailAddress: validateEmail, + confirmPassword: validatePassword, + confirmEmailAddress: validateRepeat( + 'emailAddress', + t('talk-plugin-local-auth.add_email.confirm_email_address') + ), + }; + + componentDidMount() { + this.props.startAttach(); + } + + onChange = e => { + const { name, value } = e.target; + this.setState( + state => ({ + formData: { + ...state.formData, + [name]: value, + }, + }), + () => { + this.validate(); + } + ); + }; + + validateField = (value, name) => { + const error = this.fields[name](value, this.state.formData); + if (error) { + this.addError({ [name]: error }); + return false; + } + this.removeError(name); + return true; + }; + + addError = err => { + this.setState(({ errors }) => ({ + errors: { ...errors, ...err }, + })); + }; + + validate() { + let hasErrors = false; + Object.keys(this.state.formData).forEach(k => { + hasErrors = !this.validateField(this.state.formData[k], k) || hasErrors; + }); + return !hasErrors; + } + + removeError = errKey => { + this.setState(state => { + const { [errKey]: _, ...errors } = state.errors; + return { + errors, + }; + }); + }; + + showErrors = () => { + this.setState({ + showErrors: true, + }); + }; + + finish = () => { + this.props.finishAttach(); + }; + + confirmChanges = async e => { + e.preventDefault(); + + if (!this.validate()) { + this.showErrors(); + return; + } + + const { emailAddress, confirmPassword } = this.state.formData; + const { attachLocalAuth } = this.props; + + try { + await attachLocalAuth({ + email: emailAddress, + password: confirmPassword, + }); + + this.props.notify( + 'success', + t('talk-plugin-local-auth.add_email.added.alert') + ); + this.goToNextStep(); + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + } + }; + + goToNextStep = () => { + this.setState(({ step }) => ({ + step: step + 1, + })); + }; + + render() { + const { errors, formData, showErrors, step } = this.state; + const { root: { settings } } = this.props; + + return ( + + {step === 0 && ( + + )} + {step === 1 && + !settings.requireEmailConfirmation && ( + + )} + {step === 1 && + settings.requireEmailConfirmation && ( + + )} + + ); + } +} + +AddEmailAddressDialog.propTypes = { + attachLocalAuth: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, + startAttach: PropTypes.func.isRequired, + finishAttach: PropTypes.func.isRequired, +}; + +export default AddEmailAddressDialog; diff --git a/plugins/talk-plugin-local-auth/client/components/AddEmailContent.js b/plugins/talk-plugin-local-auth/client/components/AddEmailContent.js new file mode 100644 index 000000000..de296c812 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/AddEmailContent.js @@ -0,0 +1,101 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './AddEmailAddressDialog.css'; +import { Icon } from 'plugin-api/beta/client/components/ui'; +import cn from 'classnames'; +import InputField from './InputField'; +import { t } from 'plugin-api/beta/client/services'; + +const AddEmailContent = ({ + formData, + errors, + showErrors, + confirmChanges, + onChange, +}) => ( +
    +

    + {t('talk-plugin-local-auth.add_email.content.title')} +

    +

    + {t('talk-plugin-local-auth.add_email.content.description')} +

    +
      +
    • + + + {t('talk-plugin-local-auth.add_email.content.item_1')} + +
    • +
    • + + + {t('talk-plugin-local-auth.add_email.content.item_2')} + +
    • +
    • + + + {t('talk-plugin-local-auth.add_email.content.item_3')} + +
    • +
    + +
    + + + +
    + +
    + +
    +); + +AddEmailContent.propTypes = { + formData: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + showErrors: PropTypes.bool.isRequired, + confirmChanges: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default AddEmailContent; diff --git a/plugins/talk-plugin-local-auth/client/components/ChangeEmailContentDialog.css b/plugins/talk-plugin-local-auth/client/components/ChangeEmailContentDialog.css new file mode 100644 index 000000000..a57c3662b --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeEmailContentDialog.css @@ -0,0 +1,77 @@ +.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: 320px; + top: 10px; + font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif; + font-size: 14px; + border-radius: 4px; + padding: 12px 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.3em; + margin-bottom: 8px; +} + +.description { + font-size: 1em; + line-height: 20px; + margin: 0; +} + +.item { + display: block; + color: #4C4C4D; + font-size: 1em; + margin-bottom: 2px; +} + +.bottomActions { + text-align: right; +} + +.emailChange { + margin: 18px 0; +} + +.cancel { + border: 1px solid #787d80; + background-color: transparent; + height: 30px; + font-size: 0.9em; + line-height: normal; + + &:hover { + background-color: #eaeaea; + } +} + +.confirmChanges { + background-color: #3498DB; + border-color: #3498DB; + color: white; + height: 30px; + font-size: 0.9em; + + &:hover { + background-color: #3ba3ec; + color: white; + } +} \ No newline at end of file diff --git a/plugins/talk-plugin-local-auth/client/components/ChangeEmailContentDialog.js b/plugins/talk-plugin-local-auth/client/components/ChangeEmailContentDialog.js new file mode 100644 index 000000000..f671c065b --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeEmailContentDialog.js @@ -0,0 +1,167 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './ChangeEmailContentDialog.css'; +import InputField from './InputField'; +import { Button } from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; +import validate from 'coral-framework/helpers/validate'; +import errorMsj from 'coral-framework/helpers/error'; + +const initialState = { + showError: false, + formData: { + confirmPassword: '', + }, + errors: {}, +}; + +class ChangeEmailContentDialog extends React.Component { + state = initialState; + + clearForm = () => { + this.setState(initialState); + }; + + addError = err => { + this.setState(({ errors }) => ({ + errors: { ...errors, ...err }, + })); + }; + + removeError = errKey => { + this.setState(state => { + const { [errKey]: _, ...errors } = state.errors; + return { + errors, + }; + }); + }; + + fieldValidation = (value, type, name) => { + if (!value.length) { + this.addError({ + [name]: t('talk-plugin-local-auth.change_password.required_field'), + }); + } else if (!validate[type](value)) { + this.addError({ [name]: errorMsj[type] }); + } else { + this.removeError(name); + } + }; + + onChange = e => { + const { name, value, type, dataset } = e.target; + const validationType = dataset.validationType || type; + + this.setState( + state => ({ + formData: { + ...state.formData, + [name]: value, + }, + }), + () => { + this.fieldValidation(value, validationType, name); + } + ); + }; + + hasError = err => { + return Object.keys(this.state.errors).indexOf(err) !== -1; + }; + + getError = errorKey => { + return this.state.errors[errorKey]; + }; + + showError = () => { + this.setState({ + showError: true, + }); + }; + + cancel = () => { + this.clearForm(); + this.props.closeDialog(); + }; + + confirmChanges = async e => { + e.preventDefault(); + + const { confirmPassword = '' } = this.state.formData; + + if (this.formHasError()) { + this.showError(); + return; + } + + await this.props.save(confirmPassword); + this.props.next(); + }; + + formHasError = () => this.hasError('confirmPassword'); + + render() { + return ( +
    + + × + +

    + {t('talk-plugin-local-auth.change_email.confirm_email_change')} +

    +
    +

    + {t('talk-plugin-local-auth.change_email.description')} +

    +
    + + {t('talk-plugin-local-auth.change_email.old_email')}:{' '} + {this.props.email} + + + {t('talk-plugin-local-auth.change_email.new_email')}:{' '} + {this.props.formData.newEmail} + +
    +
    + +
    + + +
    + +
    +
    + ); + } +} + +ChangeEmailContentDialog.propTypes = { + next: PropTypes.func, + save: PropTypes.func, + formData: PropTypes.object, + email: PropTypes.string, + closeDialog: PropTypes.func, +}; + +export default ChangeEmailContentDialog; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.css b/plugins/talk-plugin-local-auth/client/components/ChangePassword.css similarity index 82% rename from plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.css rename to plugins/talk-plugin-local-auth/client/components/ChangePassword.css index e94247ac1..c1c6c9c7c 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.css +++ b/plugins/talk-plugin-local-auth/client/components/ChangePassword.css @@ -1,22 +1,30 @@ .container { position: relative; color: #202020; - padding: 10px; border-radius: 2px; border: solid 1px transparent; box-sizing: border-box; justify-content: space-between; - + margin: 16px 0; + &.editing { - border-color: #979797; + padding: 10px; background-color: #EDEDED; + + .actions { + top: 10px; + right: 10px; + } + .title { + margin-bottom: 1em; + } } } .actions { position: absolute; - top: 10px; - right: 10px; + top: -6px; + right: 0px; display: flex; flex-direction: column; align-items: center; @@ -24,18 +32,18 @@ .title { color: #202020; - margin: 0 0 20px; + margin: 0; } .detailBottomBox { display: block; padding-top: 4px; text-align: right; - width: 280px; + width: 230px; } .detailLink { - color: #00538A; + color: #00538A; text-decoration: none; font-size: 0.9em; &:hover { @@ -47,7 +55,7 @@ border: 1px solid #787d80; background-color: transparent; height: 30px; - font-size: 1em; + font-size: 0.9em; line-height: normal; } @@ -59,7 +67,7 @@ > i { font-size: 17px; } - + &:hover { background-color: #399ee2; color: white; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.js b/plugins/talk-plugin-local-auth/client/components/ChangePassword.js similarity index 65% rename from plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.js rename to plugins/talk-plugin-local-auth/client/components/ChangePassword.js index 3479b5dd9..010df0826 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.js +++ b/plugins/talk-plugin-local-auth/client/components/ChangePassword.js @@ -2,12 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import cn from 'classnames'; import styles from './ChangePassword.css'; -import { Button } from 'plugin-api/beta/client/components/ui'; +import { Button, BareButton } from 'plugin-api/beta/client/components/ui'; import validate from 'coral-framework/helpers/validate'; import errorMsj from 'coral-framework/helpers/error'; import isEqual from 'lodash/isEqual'; import { t } from 'plugin-api/beta/client/services'; -import Form from './Form'; import InputField from './InputField'; import { getErrorMessages } from 'coral-framework/utils'; @@ -15,7 +14,11 @@ const initialState = { editing: false, showErrors: true, errors: {}, - formData: {}, + formData: { + oldPassword: '', + newPassword: '', + confirmNewPassword: '', + }, }; class ChangePassword extends React.Component { @@ -46,7 +49,9 @@ class ChangePassword extends React.Component { const cond = this.state.formData[field] === this.state.formData[field2]; if (!cond) { this.addError({ - [field2]: t('talk-plugin-auth.change_password.passwords_dont_match'), + [field2]: t( + 'talk-plugin-local-auth.change_password.passwords_dont_match' + ), }); } else { this.removeError(field2); @@ -57,7 +62,7 @@ class ChangePassword extends React.Component { fieldValidation = (value, type, name) => { if (!value.length) { this.addError({ - [name]: t('talk-plugin-auth.change_password.required_field'), + [name]: t('talk-plugin-local-auth.change_password.required_field'), }); } else if (!validate[type](value)) { this.addError({ [name]: errorMsj[type] }); @@ -104,7 +109,13 @@ class ChangePassword extends React.Component { this.setState(initialState); }; - onSave = async () => { + onSave = async e => { + e.preventDefault(); + + if (this.isSubmitBlocked()) { + return; + } + const { oldPassword, newPassword } = this.state.formData; try { @@ -114,7 +125,23 @@ class ChangePassword extends React.Component { }); this.props.notify( 'success', - t('talk-plugin-auth.change_password.changed_password_msg') + t('talk-plugin-local-auth.change_password.changed_password_msg') + ); + this.clearForm(); + this.disableEditing(); + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + } + }; + + onForgotPassword = async () => { + const { root: { me: { email } } } = this.props; + + try { + await this.props.forgotPassword(email); + this.props.notify( + 'success', + t('talk-plugin-local-auth.change_password.forgot_password_sent') ); } catch (err) { this.props.notify('error', getErrorMessages(err)); @@ -136,19 +163,26 @@ class ChangePassword extends React.Component { }; render() { - const { editing, errors } = this.state; + const { editing, errors, showErrors } = this.state; return (

    - {t('talk-plugin-auth.change_password.change_password')} + {t('talk-plugin-local-auth.change_password.change_password')}

    - {editing && ( -
    + {editing ? ( + - - {t('talk-plugin-auth.change_password.forgot_password')} + + {t('talk-plugin-local-auth.change_password.forgot_password')} @@ -175,7 +212,7 @@ class ChangePassword extends React.Component { value={this.state.formData.newPassword} hasError={this.hasError('newPassword')} errorMsg={errors['newPassword']} - showErrors + showError={showErrors} /> - - )} - {editing ? ( -
    - - - {t('talk-plugin-auth.change_password.cancel')} - -
    +
    + + + {t('talk-plugin-local-auth.change_password.cancel')} + +
    + ) : (
    )} @@ -218,6 +257,7 @@ class ChangePassword extends React.Component { ChangePassword.propTypes = { changePassword: PropTypes.func.isRequired, + forgotPassword: PropTypes.func.isRequired, notify: PropTypes.func.isRequired, }; diff --git a/plugins/talk-plugin-local-auth/client/components/ChangeUsernameContentDialog.css b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameContentDialog.css new file mode 100644 index 000000000..2f3cdd14d --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameContentDialog.css @@ -0,0 +1,73 @@ +.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.3em; + margin-bottom: 8px; +} + +.description { + font-size: 1em; + line-height: 20px; + margin: 0; +} + +.item { + display: block; + color: #4C4C4D; + font-size: 1em; + margin-bottom: 2px; +} + +.bottomNote { + font-size: 0.9em; + line-height: 20px; + padding-top: 10px; + display: block; +} + +.bottomActions { + text-align: right; +} + +.usernamesChange { + margin: 18px 0; +} + +.cancel { + border: 1px solid #787d80; + background-color: transparent; + height: 30px; + font-size: 0.9em; + line-height: normal; + + &:hover { + background-color: #eaeaea; + } +} + +.confirmChanges { + background-color: #3498DB; + border-color: #3498DB; + color: white; + height: 30px; + font-size: 0.9em; + + &:hover { + background-color: #3ba3ec; + color: white; + } +} \ No newline at end of file diff --git a/plugins/talk-plugin-local-auth/client/components/ChangeUsernameContentDialog.js b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameContentDialog.js new file mode 100644 index 000000000..3e7107922 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameContentDialog.js @@ -0,0 +1,116 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './ChangeUsernameContentDialog.css'; +import InputField from './InputField'; +import { Button } from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; + +class ChangeUsernameContentDialog extends React.Component { + state = { + showError: false, + }; + + showError = () => { + this.setState({ + showError: true, + }); + }; + + confirmChanges = async e => { + e.preventDefault(); + + if (this.formHasError()) { + this.showError(); + return; + } + + if (!this.props.canUsernameBeUpdated) { + this.props.notify( + 'error', + t('talk-plugin-local-auth.change_username.change_username_attempt') + ); + return; + } + + await this.props.save(); + this.props.next(); + }; + + formHasError = () => + this.props.formData.confirmNewUsername !== this.props.formData.newUsername; + + render() { + return ( +
    + + × + +

    + {t('talk-plugin-local-auth.change_username.confirm_username_change')} +

    +
    +

    + {t('talk-plugin-local-auth.change_username.description')} +

    +
    + + {t('talk-plugin-local-auth.change_username.old_username')}:{' '} + {this.props.username} + + + {t('talk-plugin-local-auth.change_username.new_username')}:{' '} + {this.props.formData.newUsername} + +
    +
    + + + {t('talk-plugin-local-auth.change_username.bottom_note')} + + +
    + + +
    +
    +
    +
    + ); + } +} + +ChangeUsernameContentDialog.propTypes = { + save: PropTypes.func, + next: PropTypes.func, + cancel: PropTypes.func, + onChange: PropTypes.func, + formData: PropTypes.object, + username: PropTypes.string, + canUsernameBeUpdated: PropTypes.bool.isRequired, + notify: PropTypes.func.isRequired, +}; + +export default ChangeUsernameContentDialog; diff --git a/plugins/talk-plugin-local-auth/client/components/ConfirmChangesDialog.css b/plugins/talk-plugin-local-auth/client/components/ConfirmChangesDialog.css new file mode 100644 index 000000000..daa884404 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ConfirmChangesDialog.css @@ -0,0 +1,10 @@ +.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: 320px; + top: 10px; + font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif; + font-size: 14px; + border-radius: 4px; + padding: 12px 20px; +} \ No newline at end of file diff --git a/plugins/talk-plugin-local-auth/client/components/ConfirmChangesDialog.js b/plugins/talk-plugin-local-auth/client/components/ConfirmChangesDialog.js new file mode 100644 index 000000000..35995f654 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ConfirmChangesDialog.js @@ -0,0 +1,75 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './ConfirmChangesDialog.css'; +import { Dialog } from 'plugin-api/beta/client/components/ui'; + +const initialState = { step: 0 }; + +class ConfirmChangesDialog extends React.Component { + state = initialState; + + goToNextStep = () => { + this.setState(({ step }) => ({ + step: step + 1, + })); + }; + + clear = () => { + this.setState(initialState); + }; + + cancel = () => { + this.clear(); + this.props.closeDialog(); + }; + + continue = () => { + this.goToNextStep(); + }; + + finish = () => { + this.clear(); + this.props.closeDialog(); + this.props.finish(); + }; + + renderSteps = () => { + const steps = React.Children.toArray(this.props.children) + .filter(child => child.props.enable) + .filter((_, i) => i === this.state.step); + + return steps.map(child => { + return React.cloneElement(child, { + goToNextStep: this.goToNextStep, + clear: this.clear, + cancel: this.cancel, + next: + this.state.step === steps.length - 1 ? this.finish : this.continue, + }); + }); + }; + + render() { + return ( + + {this.renderSteps()} + + ); + } +} + +ConfirmChangesDialog.propTypes = { + children: PropTypes.node, + closeDialog: PropTypes.func, + showDialog: PropTypes.bool, + finish: PropTypes.func, +}; + +export default ConfirmChangesDialog; diff --git a/plugins/talk-plugin-local-auth/client/components/EmailAddressAdded.js b/plugins/talk-plugin-local-auth/client/components/EmailAddressAdded.js new file mode 100644 index 000000000..6d5775f3c --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/EmailAddressAdded.js @@ -0,0 +1,32 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './AddEmailAddressDialog.css'; +import { t } from 'plugin-api/beta/client/services'; + +const EmailAddressAdded = ({ done }) => ( +
    +

    + {t('talk-plugin-local-auth.add_email.added.title')} +

    +

    + {t('talk-plugin-local-auth.add_email.added.description')} +

    + {t('talk-plugin-local-auth.add_email.added.subtitle')} +

    + {t('talk-plugin-local-auth.add_email.added.description_2')}{' '} + {t('talk-plugin-local-auth.add_email.added.path')}. +

    + +
    +); + +EmailAddressAdded.propTypes = { + done: PropTypes.func.isRequired, +}; + +export default EmailAddressAdded; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css b/plugins/talk-plugin-local-auth/client/components/ErrorMessage.css similarity index 80% rename from plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css rename to plugins/talk-plugin-local-auth/client/components/ErrorMessage.css index d7a1b6d45..abcf692fe 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css +++ b/plugins/talk-plugin-local-auth/client/components/ErrorMessage.css @@ -1,6 +1,5 @@ .errorMsg { color: #FA4643; - padding-left: 4px; font-size: 0.9em; } diff --git a/plugins/talk-plugin-local-auth/client/components/ErrorMessage.js b/plugins/talk-plugin-local-auth/client/components/ErrorMessage.js new file mode 100644 index 000000000..f39a8fc08 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ErrorMessage.js @@ -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 }) => ( +
    + + {children} +
    +); + +ErrorMessage.propTypes = { + children: PropTypes.node, +}; + +export default ErrorMessage; diff --git a/plugins/talk-plugin-local-auth/client/components/InputField.css b/plugins/talk-plugin-local-auth/client/components/InputField.css new file mode 100644 index 000000000..fae31c80a --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/InputField.css @@ -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; +} diff --git a/plugins/talk-plugin-local-auth/client/components/InputField.js b/plugins/talk-plugin-local-auth/client/components/InputField.js new file mode 100644 index 000000000..7dca22197 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/InputField.js @@ -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 !== undefined ? { value } : {}), + ...(defaultValue !== undefined ? { defaultValue } : {}), + }; + + return ( +
    +
    + {label && ( + + )} +
    +
    + {icon && } + +
    +
    + {!hasError && + showSuccess && + value && ( + + )} + {hasError && showError && {errorMsg}} +
    +
    +
    + {children} +
    + ); +}; + +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; diff --git a/plugins/talk-plugin-local-auth/client/components/Profile.css b/plugins/talk-plugin-local-auth/client/components/Profile.css new file mode 100644 index 000000000..920b93260 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/Profile.css @@ -0,0 +1,134 @@ +.container { + margin-top: 6px; + margin-bottom: 12px; + display: flex; + position: relative; + color: #202020; + padding: 5px; + border-radius: 2px; + box-sizing: border-box; + justify-content: space-between; + + &.editing { + padding: 10px; + background-color: #EDEDED; + } +} + +.wrapper { + display: flex; + position: relative; + box-sizing: inherit; + justify-content: inherit; + flex-grow: 1; +} + +.content { + flex-grow: 1; +} + +.actions { + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.email { + margin: 0; +} + +.username { + margin-top: 0; + margin-bottom: 4px; +} + +.button { + border: 1px solid #787d80; + background-color: transparent; + height: 30px; + font-size: 0.9em; + line-height: normal; +} + +.saveButton { + background-color: #3498DB; + border-color: #3498DB; + color: white; + + > i { + font-size: 17px; + } + + &:hover { + background-color: #399ee2; + color: white; + } + + &:disabled { + border-color: #e0e0e0; + + &:hover { + background-color: #e0e0e0; + color: #4f5c67; + cursor: default; + } + } +} + +.cancelButton { + color:#787D80; + margin-top: 6px; + font-size: 0.9em; + + &:hover { + cursor: pointer; + } +} + +.detailLabel { + border: solid 1px #787D80; + border-radius: 2px; + background-color: white; + height: 30px; + display: inline-block; + width: 230px; + display: flex; + + > .detailLabelIcon { + font-size: 1.2em; + padding: 0 5px; + color: #787D80; + line-height: 30px; + } + + &.disabled { + background-color: #E0E0E0; + } +} + +.detailValue { + background: transparent; + border: none; + font-size: 1em; + color: #000; + height: 30px; + outline: none; + flex: 1; +} + +.bottomText { + color: #474747; + font-size: 0.9em; +} + +.detailList { + list-style: none; + margin: 0; + padding: 0; +} + +.detailItem { + margin-bottom: 12px; +} diff --git a/plugins/talk-plugin-local-auth/client/components/Profile.js b/plugins/talk-plugin-local-auth/client/components/Profile.js new file mode 100644 index 000000000..caa09543b --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/Profile.js @@ -0,0 +1,302 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './Profile.css'; +import { Button, BareButton } from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; +import InputField from './InputField'; +import { getErrorMessages } from 'coral-framework/utils'; +import validate from 'coral-framework/helpers/validate'; +import errorMsj from 'coral-framework/helpers/error'; +import ConfirmChangesDialog from './ConfirmChangesDialog'; +import ChangeUsernameContentDialog from './ChangeUsernameContentDialog'; +import ChangeEmailContentDialog from './ChangeEmailContentDialog'; +import { canUsernameBeUpdated } from 'coral-framework/utils/user'; + +const initialState = { + editing: false, + showDialog: false, + formData: {}, + errors: {}, +}; + +class Profile extends React.Component { + state = initialState; + + clearForm = () => { + this.setState(initialState); + }; + + enableEditing = () => { + this.setState({ + editing: true, + }); + }; + + disableEditing = () => { + this.setState({ + editing: false, + }); + }; + + cancel = () => { + this.clearForm(); + this.disableEditing(); + }; + + showDialog = () => { + this.setState({ + showDialog: true, + }); + }; + + onSave = async e => { + e.preventDefault(); + + if (this.isSaveEnabled()) { + this.showDialog(); + } + }; + + addError = err => { + this.setState(({ errors }) => ({ + errors: { ...errors, ...err }, + })); + }; + + removeError = errKey => { + this.setState(state => { + const { [errKey]: _, ...errors } = state.errors; + return { + errors, + }; + }); + }; + + fieldValidation = (value, type, name) => { + if (!value.length) { + this.addError({ + [name]: t('talk-plugin-local-auth.change_password.required_field'), + }); + } else if (!validate[type](value)) { + this.addError({ [name]: errorMsj[type] }); + } else { + this.removeError(name); + } + }; + + onChange = e => { + const { name, value, type, dataset } = e.target; + const validationType = dataset.validationType || type; + + this.setState( + state => ({ + formData: { + ...state.formData, + [name]: value, + }, + }), + () => { + this.fieldValidation(value, validationType, name); + } + ); + }; + + closeDialog = () => { + this.setState({ + showDialog: false, + }); + }; + + hasError = err => { + return Object.keys(this.state.errors).indexOf(err) !== -1; + }; + + isSaveEnabled = () => { + const { formData } = this.state; + const { root: { me: { username, email } } } = this.props; + const formHasErrors = !!Object.keys(this.state.errors).length; + const validUsername = + formData.newUsername && formData.newUsername !== username; + const validEmail = formData.newEmail && formData.newEmail !== email; + + return !formHasErrors && (validUsername || validEmail); + }; + + saveUsername = async () => { + const { newUsername } = this.state.formData; + const { setUsername } = this.props; + + try { + await setUsername(newUsername); + this.props.notify( + 'success', + t('talk-plugin-local-auth.change_username.changed_username_success_msg') + ); + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + } + }; + + saveEmail = async confirmPassword => { + const { newEmail } = this.state.formData; + + try { + await this.props.updateEmailAddress({ + email: newEmail, + confirmPassword, + }); + this.props.notify( + 'success', + t('talk-plugin-local-auth.change_email.change_email_msg') + ); + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + } + }; + + getError = errorKey => { + return this.state.errors[errorKey]; + }; + + finish = () => { + this.clearForm(); + this.disableEditing(); + }; + + render() { + const { + root: { me: { username, email, state: { status } } }, + notify, + } = this.props; + const { editing, formData, showDialog } = this.state; + + const usernameCanBeUpdated = canUsernameBeUpdated(status); + + return ( +
    + + {usernameCanBeUpdated && ( + + )} + + + + {editing ? ( +
    +
    +
    + +
    + + {t( + 'talk-plugin-local-auth.change_username.change_username_note' + )} + + {!usernameCanBeUpdated && ( + + {' '} + {t( + 'talk-plugin-local-auth.change_username.is_not_eligible' + )} + + )} +
    +
    + +
    +
    +
    + + + {t('talk-plugin-local-auth.change_username.cancel')} + +
    +
    + ) : ( +
    +
    +

    {username}

    + {email ?

    {email}

    : null} +
    +
    + +
    +
    + )} +
    + ); + } +} + +Profile.propTypes = { + updateEmailAddress: PropTypes.func.isRequired, + setUsername: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, + username: PropTypes.string, + emailAddress: PropTypes.string, +}; + +export default Profile; diff --git a/plugins/talk-plugin-local-auth/client/components/VerifyEmailAddress.js b/plugins/talk-plugin-local-auth/client/components/VerifyEmailAddress.js new file mode 100644 index 000000000..cc1ff3626 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/VerifyEmailAddress.js @@ -0,0 +1,28 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './AddEmailAddressDialog.css'; +import { t } from 'plugin-api/beta/client/services'; + +const VerifyEmailAddress = ({ emailAddress, done }) => ( +
    +

    + {t('talk-plugin-local-auth.add_email.verify.title')} +

    +

    + {t('talk-plugin-local-auth.add_email.verify.description', emailAddress)} +

    + +
    +); + +VerifyEmailAddress.propTypes = { + emailAddress: PropTypes.string.isRequired, + done: PropTypes.func.isRequired, +}; + +export default VerifyEmailAddress; diff --git a/plugins/talk-plugin-local-auth/client/constants.js b/plugins/talk-plugin-local-auth/client/constants.js new file mode 100644 index 000000000..d410a10fa --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/constants.js @@ -0,0 +1,4 @@ +const prefix = 'TALK_LOCAL_AUTH'; + +export const START_ATTACH = `${prefix}_START_ATTACH`; +export const FINISH_ATTACH = `${prefix}_FINISH_ATTACH`; diff --git a/plugins/talk-plugin-local-auth/client/containers/AddEmailAddressDialog.js b/plugins/talk-plugin-local-auth/client/containers/AddEmailAddressDialog.js new file mode 100644 index 000000000..f6de3b39c --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/containers/AddEmailAddressDialog.js @@ -0,0 +1,48 @@ +import { compose, gql } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { connect, withFragments, excludeIf } from 'plugin-api/beta/client/hocs'; +import AddEmailAddressDialog from '../components/AddEmailAddressDialog'; +import { notify } from 'coral-framework/actions/notification'; +import { withAttachLocalAuth } from '../hocs'; +import { startAttach, finishAttach } from '../actions'; +import get from 'lodash/get'; + +const mapStateToProps = ({ talkPluginLocalAuth: state }) => ({ + inProgress: state.inProgress, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators({ notify, startAttach, finishAttach }, dispatch); + +const withData = withFragments({ + root: gql` + fragment TalkPluginLocalAuth_AddEmailAddressDialog_root on RootQuery { + me { + id + email + state { + status { + username { + status + } + } + } + } + settings { + requireEmailConfirmation + } + } + `, +}); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withAttachLocalAuth, + withData, + excludeIf( + ({ root: { me }, inProgress }) => + !me || + get(me, 'state.status.username.status') === 'UNSET' || + (me.email && !inProgress) + ) +)(AddEmailAddressDialog); diff --git a/plugins/talk-plugin-local-auth/client/containers/ChangePassword.js b/plugins/talk-plugin-local-auth/client/containers/ChangePassword.js new file mode 100644 index 000000000..11bd5c5fb --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/containers/ChangePassword.js @@ -0,0 +1,28 @@ +import { compose, gql } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { connect, withFragments } from 'plugin-api/beta/client/hocs'; +import ChangePassword from '../components/ChangePassword'; +import { notify } from 'coral-framework/actions/notification'; +import { + withChangePassword, + withForgotPassword, +} from 'plugin-api/beta/client/hocs'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +const withData = withFragments({ + root: gql` + fragment TalkPluginLocalAuth_ChangePassword_root on RootQuery { + me { + email + } + } + `, +}); + +export default compose( + connect(null, mapDispatchToProps), + withChangePassword, + withForgotPassword, + withData +)(ChangePassword); diff --git a/plugins/talk-plugin-local-auth/client/containers/Profile.js b/plugins/talk-plugin-local-auth/client/containers/Profile.js new file mode 100644 index 000000000..ccfca72ae --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/containers/Profile.js @@ -0,0 +1,39 @@ +import { compose, gql } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { connect, withFragments } from 'plugin-api/beta/client/hocs'; +import Profile from '../components/Profile'; +import { notify } from 'coral-framework/actions/notification'; +import { withSetUsername } from 'plugin-api/beta/client/hocs'; +import { withUpdateEmailAddress } from '../hocs'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +const withData = withFragments({ + root: gql` + fragment TalkPluginLocalAuth_Profile_root on RootQuery { + me { + id + email + username + state { + status { + username { + status + history { + status + created_at + } + } + } + } + } + } + `, +}); + +export default compose( + connect(null, mapDispatchToProps), + withSetUsername, + withUpdateEmailAddress, + withData +)(Profile); diff --git a/plugins/talk-plugin-local-auth/client/graphql.js b/plugins/talk-plugin-local-auth/client/graphql.js new file mode 100644 index 000000000..eddf58315 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/graphql.js @@ -0,0 +1,35 @@ +import update from 'immutability-helper'; +import get from 'lodash/get'; +import findIndex from 'lodash/findIndex'; + +export default { + mutations: { + UpdateEmailAddress: () => ({ + updateQueries: { + CoralEmbedStream_Profile: previousData => { + // Find the local profile (if they have one). + const localIndex = findIndex(get(previousData, 'me.profiles', []), { + provider: 'local', + }); + if (localIndex < 0) { + return previousData; + } + + // Mutate the confirmedAt, because we changed the email address, they + // can't possibly be confirmed now as well. + return update(previousData, { + me: { + profiles: { + [localIndex]: { + confirmedAt: { + $set: null, + }, + }, + }, + }, + }); + }, + }, + }), + }, +}; diff --git a/plugins/talk-plugin-local-auth/client/hocs/index.js b/plugins/talk-plugin-local-auth/client/hocs/index.js new file mode 100644 index 000000000..573fa6b10 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/hocs/index.js @@ -0,0 +1,91 @@ +import { withMutation } from 'plugin-api/beta/client/hocs'; +import { gql } from 'react-apollo'; +import update from 'immutability-helper'; + +export const withAttachLocalAuth = withMutation( + gql` + mutation AttachLocalAuth($input: AttachLocalAuthInput!) { + attachLocalAuth(input: $input) { + ...AttachLocalAuthResponse + } + } + `, + { + props: ({ mutate }) => ({ + attachLocalAuth: input => { + return mutate({ + variables: { + input, + }, + update: proxy => { + const AttachLocalAuthQuery = gql` + query Talk_AttachLocalAuth { + me { + id + email + } + } + `; + + const prev = proxy.readQuery({ query: AttachLocalAuthQuery }); + + const data = update(prev, { + me: { + email: { $set: input.email }, + }, + }); + + proxy.writeQuery({ + query: AttachLocalAuthQuery, + data, + }); + }, + }); + }, + }), + } +); + +export const withUpdateEmailAddress = withMutation( + gql` + mutation UpdateEmailAddress($input: UpdateEmailAddressInput!) { + updateEmailAddress(input: $input) { + ...UpdateEmailAddressResponse + } + } + `, + { + props: ({ mutate }) => ({ + updateEmailAddress: input => { + return mutate({ + variables: { + input, + }, + update: proxy => { + const UpdateEmailAddressQuery = gql` + query Talk_UpdateEmailAddress { + me { + id + email + } + } + `; + + const prev = proxy.readQuery({ query: UpdateEmailAddressQuery }); + + const data = update(prev, { + me: { + email: { $set: input.email }, + }, + }); + + proxy.writeQuery({ + query: UpdateEmailAddressQuery, + data, + }); + }, + }); + }, + }), + } +); diff --git a/plugins/talk-plugin-local-auth/client/index.js b/plugins/talk-plugin-local-auth/client/index.js new file mode 100644 index 000000000..a4dc01ac2 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/index.js @@ -0,0 +1,17 @@ +import ChangePassword from './containers/ChangePassword'; +import AddEmailAddressDialog from './containers/AddEmailAddressDialog'; +import Profile from './containers/Profile'; +import translations from '../translations.yml'; +import graphql from './graphql'; +import reducer from './reducer'; + +export default { + reducer, + translations, + slots: { + profileHeader: [Profile], + profileSettings: [ChangePassword], + stream: [AddEmailAddressDialog], + }, + ...graphql, +}; diff --git a/plugins/talk-plugin-local-auth/client/reducer.js b/plugins/talk-plugin-local-auth/client/reducer.js new file mode 100644 index 000000000..11666e505 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/reducer.js @@ -0,0 +1,20 @@ +import * as actions from './constants'; + +const initialState = { + inProgress: false, +}; + +export default function reducer(state = initialState, action) { + switch (action.type) { + case actions.START_ATTACH: + return { + inProgress: true, + }; + case actions.FINISH_ATTACH: + return { + inProgress: false, + }; + default: + return state; + } +} diff --git a/plugins/talk-plugin-local-auth/index.js b/plugins/talk-plugin-local-auth/index.js new file mode 100644 index 000000000..fabe1dd2b --- /dev/null +++ b/plugins/talk-plugin-local-auth/index.js @@ -0,0 +1,11 @@ +const typeDefs = require('./server/typeDefs'); +const resolvers = require('./server/resolvers'); +const mutators = require('./server/mutators'); +const path = require('path'); + +module.exports = { + translations: path.join(__dirname, 'translations.yml'), + typeDefs, + mutators, + resolvers, +}; diff --git a/plugins/talk-plugin-local-auth/server/errors.js b/plugins/talk-plugin-local-auth/server/errors.js new file mode 100644 index 000000000..0f0653c19 --- /dev/null +++ b/plugins/talk-plugin-local-auth/server/errors.js @@ -0,0 +1,35 @@ +const { TalkError } = require('errors'); + +// ErrNoLocalProfile is returned when there is no existing local profile +// attached to a user. +class ErrNoLocalProfile extends TalkError { + constructor() { + super('No local profile associated with account', { + translation_key: 'NO_LOCAL_PROFILE', + status: 400, + }); + } +} + +// ErrLocalProfile is returned when a profile is already attached to a user and +// the user is trying to attach a new profile to it. +class ErrLocalProfile extends TalkError { + constructor() { + super('Local profile already associated with account', { + translation_key: 'LOCAL_PROFILE', + status: 400, + }); + } +} + +// ErrIncorrectPassword is returned when the password passed was incorrect. +class ErrIncorrectPassword extends TalkError { + constructor() { + super('Password was incorrect', { + translation_key: 'INCORRECT_PASSWORD', + status: 400, + }); + } +} + +module.exports = { ErrLocalProfile, ErrNoLocalProfile, ErrIncorrectPassword }; diff --git a/plugins/talk-plugin-local-auth/server/mutators.js b/plugins/talk-plugin-local-auth/server/mutators.js new file mode 100644 index 000000000..7049cd546 --- /dev/null +++ b/plugins/talk-plugin-local-auth/server/mutators.js @@ -0,0 +1,163 @@ +const { ErrNotAuthorized, ErrNotFound, ErrEmailTaken } = require('errors'); +const { + ErrNoLocalProfile, + ErrLocalProfile, + ErrIncorrectPassword, +} = require('./errors'); +const { get } = require('lodash'); + +// hasLocalProfile checks a user's profiles to see if they already have a local +// profile associated with their account. +const hasLocalProfile = user => + get(user, 'profiles', []).some(({ provider }) => provider === 'local'); + +// updateUserEmailAddress will verify that the user has sent the correct +// password followed by executing the email change and notifying the emails +// about that change. +async function updateUserEmailAddress(ctx, email, confirmPassword) { + const { + user, + loaders: { Settings }, + connectors: { models: { User }, services: { Mailer, I18n, Users } }, + } = ctx; + + // Ensure that the user has a local profile associated with their account. + if (!hasLocalProfile(user)) { + throw new ErrNoLocalProfile(); + } + + // Ensure that the password provided matches what we have on file. + if (!await user.verifyPassword(confirmPassword)) { + throw new ErrIncorrectPassword(); + } + + // Cleanup the email address. + email = email.toLowerCase().trim(); + + // Update the Users email address. + try { + await User.update( + { + id: user.id, + profiles: { $elemMatch: { provider: 'local' } }, + }, + { + $set: { 'profiles.$.id': email }, + $unset: { 'profiles.$.metadata.confirmed_at': 1 }, + } + ); + } catch (err) { + if (err.code === 11000) { + throw new ErrEmailTaken(); + } + + throw err; + } + + // Get some context for the email to be sent. + const { organizationContactEmail } = await Settings.select( + 'organizationContactEmail' + ); + + // Send off the email to the old email address that we have changed it. + await Mailer.send({ + email: user.firstEmail, + template: 'plain', + locals: { + body: I18n.t( + 'email.email_change_original.body', + user.firstEmail, + email, + organizationContactEmail + ), + }, + subject: I18n.t('email.email_change_original.subject'), + }); + + // Send off the email to the new email address that we need to verify the new + // address. + await Users.sendEmailConfirmation(user, email); +} + +// attachUserLocalAuth will attach a new local profile to an existing user. +async function attachUserLocalAuth(ctx, email, password) { + const { user, connectors: { models: { User }, services: { Users } } } = ctx; + + // Ensure that the current user doesn't already have a local account + // associated with them. + if (hasLocalProfile(user)) { + throw new ErrLocalProfile(); + } + + // Cleanup the email address. + email = email.toLowerCase().trim(); + + // Validate the password. + await Users.isValidPassword(password); + + // Hash the new password. + const hashedPassword = await Users.hashPassword(password); + + try { + // Associate the account with the user. + const updatedUser = await User.findOneAndUpdate( + { + id: user.id, + 'profiles.provider': { $ne: 'local' }, + }, + { + $push: { + profiles: { + provider: 'local', + id: email, + }, + }, + $set: { + password: hashedPassword, + }, + }, + { new: true } + ); + if (!updatedUser) { + const foundUser = await User.findOne({ id: user.id }); + if (!foundUser) { + throw new ErrNotFound(); + } + + // Check to see if this was the result of a race. + if (hasLocalProfile(foundUser)) { + throw new ErrLocalProfile(); + } + + throw new Error('local auth attachment failed due to unexpected reason'); + } + + // Send off the email to the new email address that we need to verify the + // new address. + await Users.sendEmailConfirmation(updatedUser, email); + } catch (err) { + if (err.code === 11000) { + throw new ErrEmailTaken(); + } + throw err; + } +} + +module.exports = ctx => { + const mutators = { + User: { + updateEmailAddress: () => Promise.reject(new ErrNotAuthorized()), + attachLocalAuth: () => Promise.reject(new ErrNotAuthorized()), + }, + }; + + if (ctx.user) { + mutators.User.updateEmailAddress = ({ email, confirmPassword }) => + updateUserEmailAddress(ctx, email, confirmPassword); + + mutators.User.attachLocalAuth = ({ email, password }) => + attachUserLocalAuth(ctx, email, password); + } + + return mutators; +}; diff --git a/plugins/talk-plugin-local-auth/server/resolvers.js b/plugins/talk-plugin-local-auth/server/resolvers.js new file mode 100644 index 000000000..23815de00 --- /dev/null +++ b/plugins/talk-plugin-local-auth/server/resolvers.js @@ -0,0 +1,10 @@ +module.exports = { + RootMutation: { + updateEmailAddress: async (root, { input }, { mutators: { User } }) => { + await User.updateEmailAddress(input); + }, + attachLocalAuth: async (root, { input }, { mutators: { User } }) => { + await User.attachLocalAuth(input); + }, + }, +}; diff --git a/plugins/talk-plugin-local-auth/server/typeDefs.graphql b/plugins/talk-plugin-local-auth/server/typeDefs.graphql new file mode 100644 index 000000000..6202e8842 --- /dev/null +++ b/plugins/talk-plugin-local-auth/server/typeDefs.graphql @@ -0,0 +1,47 @@ +# UpdateEmailAddressInput provides input for changing a users email address +# associated with their account. +input UpdateEmailAddressInput { + + # email is the Users email address that they want to update to. + email: String! + + # confirmPassword is the Users current password. + confirmPassword: String! +} + +# UpdateEmailAddressResponse is returned when you try to update a users email +# address. +type UpdateEmailAddressResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# AttachLocalAuthInput provides the input for attaching a new local +# authentication profile. +input AttachLocalAuthInput { + + # email is the Users email address that they want to add. + email: String! + + # password is the Users password that they want to add. + password: String! +} + +# AttachLocalAuthResponse returns any errors for when the user attempts to +# attach a new local authentication profile. +type AttachLocalAuthResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +type RootMutation { + + # updateEmailAddress changes the email address of the current logged in user. + updateEmailAddress(input: UpdateEmailAddressInput!): UpdateEmailAddressResponse + + # attachLocalAuth will attach a new local authentication profile to an + # account. + attachLocalAuth(input: AttachLocalAuthInput!): AttachLocalAuthResponse +} diff --git a/plugins/talk-plugin-local-auth/server/typeDefs.js b/plugins/talk-plugin-local-auth/server/typeDefs.js new file mode 100644 index 000000000..7ab1954e1 --- /dev/null +++ b/plugins/talk-plugin-local-auth/server/typeDefs.js @@ -0,0 +1,7 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = fs.readFileSync( + path.join(__dirname, 'typeDefs.graphql'), + 'utf8' +); diff --git a/plugins/talk-plugin-local-auth/translations.yml b/plugins/talk-plugin-local-auth/translations.yml new file mode 100644 index 000000000..ae89039c3 --- /dev/null +++ b/plugins/talk-plugin-local-auth/translations.yml @@ -0,0 +1,98 @@ +en: + email: + email_change_original: + subject: Email change + body: Your email address has been changed from {0} to {1}. If you did not request this change, please contact {2}. + error: + NO_LOCAL_PROFILE: No existing email address is associated with this account. + LOCAL_PROFILE: An email address is already associated with this account. + INCORRECT_PASSWORD: Provided password was incorrect. + talk-plugin-local-auth: + change_password: + change_password: "Change Password" + passwords_dont_match: "Passwords don`t match" + required_field: "This field is required" + forgot_password: "Forgot your password?" + save: "Save" + cancel: "Cancel" + edit: "Edit" + changed_password_msg: "Changed Password - Your password has been successfully changed" + forgot_password_sent: "Forgot Password - We sent you an email to recover your password" + change_username: + change_username_note: "Usernames can only be changed once every 14 days." + is_not_eligible: "You cannot currently change your username." + save: "Save" + edit_profile: "Edit Profile" + cancel: "Cancel" + confirm_username_change: "Confirm Username Change" + description: "You are attempting to change your username. Your new username will appear on all of your past and future comments." + old_username: "Old Username" + new_username: "New Username" + bottom_note: "Note: You will not be able to change your username again for 14 days" + confirm_changes: "Confirm Changes" + username_does_not_match: "Username does not match" + cant_be_equal: "Your new {0} must be different to your current one" + changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days." + change_username_attempt: "Username can't be updated. Usernames can only be changed every 14 days." + change_email: + confirm_email_change: "Confirm Email Address Change" + description: "You are attempting to change your email address. Your new email address will be used for your login and to receive account notifications." + old_email: "Old Email Address" + new_email: "New Email Address" + enter_password: "Enter Password" + incorrect_password: "Incorrect Password" + confirm_change: "Confirm Change" + cancel: "Cancel" + change_email_msg: "Email Address Changed - Your email address has been successfully changed. This email address will now be used for signing in and email notifications." + add_email: + add_email_address: "Add Email Address" + enter_email_address: "Enter Email Address:" + invalid_email_address: "Invalid Email address" + confirm_email_address: "Confirm Email Address:" + email_does_not_match: "Email Address does not match" + insert_password: "Insert Password:" + required_field: "This field is required" + done: "Done" + content: + title: "Add an Email Address" + description: "For your added security, we require users to add an email address to their accounts. Your email address will be used to:" + item_1: "Receive updates regarding any changes to your account (email address, username, password, etc.)" + item_2: "Allow you to download your comments." + item_3: "Send comment notifications that you have chosen to receive." + verify: + title: "Verify Your Email Address" + description: "We’ve sent an email to {0} to verify your account. You must verify your email address so that it can be used for account change confirmations and notifications." + added: + title: "Email Address Added" + description: "Your email address has been added to your account." + subtitle: "Need to change your email address?" + description_2: "You can change your account settings by visiting" + path: "My Profile > Settings" + alert: "Email Added!" +es: + talk-plugin-local-auth: + change_password: + change_password: "Cambiar Contraseña" + passwords_dont_match: "Las contraseñas no coinciden" + required_field: "Este campo es requerido" + forgot_password: "Olvidaste tu contraseña?" + save: "Guardar" + cancel: "Cancelar" + edit: "Editar" + changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada" + forgot_password_sent: "Contraseña Olvidada - Te enviamos un email para recuperar tu contraseña" + change_username: + change_username_note: "El usuario puede ser cambiado cada 14 días." + is_not_eligible: "Ahora mismo no se puede cambiar su nombre de usuario." + save: "Guardar" + edit_profile: "Editar Perfil" + cancel: "Cancelar" + confirm_username_change: "Confirmar Cambio de Usuario" + description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios." + old_username: "Usuario viejo" + new_username: "Usuario nuevo" + bottom_note: "Nota: No podrás cambiar tu usuario por 14 días" + confirm_changes: "Confirmar Cambios" + username_does_not_match: "El usuario no coincide" + changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días." + change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días." diff --git a/plugins/talk-plugin-notifications-category-reply/index.js b/plugins/talk-plugin-notifications-category-reply/index.js index cce258daa..627d50ff8 100644 --- a/plugins/talk-plugin-notifications-category-reply/index.js +++ b/plugins/talk-plugin-notifications-category-reply/index.js @@ -1,17 +1,23 @@ const { get, map } = require('lodash'); const path = require('path'); -const handle = async (ctx, comment) => { +const commentAddedHandler = async (ctx, comment) => { // Check to see if this reply is visible. if (!comment.visible) { - ctx.log.info('comment was not visible, not sending notification'); + ctx.log.info( + { commentID: comment.id }, + 'comment was not visible, not sending notification' + ); return; } // Check to see if this is a reply to an existing comment. const parentID = get(comment, 'parent_id', null); - if (parentID === null) { - ctx.log.info('could not get parent comment id'); + if (!parentID) { + ctx.log.info( + { commentID: comment.id }, + 'could not get parent comment id, comment must be a top level comment' + ); return; } @@ -40,42 +46,60 @@ const handle = async (ctx, comment) => { return; } + const parentComment = get(reply, 'data.comment'); + if (!parentComment) { + ctx.log.info({ parentID }, 'could not get parent comment'); + return; + } + // Check if the user has notifications enabled. const enabled = get( - reply, - 'data.comment.user.notificationSettings.onReply', + parentComment, + 'user.notificationSettings.onReply', false ); if (!enabled) { + ctx.log.error( + 'parent comment author does not have notification category enabled' + ); return; } - const userID = get(reply, 'data.comment.user.id', null); - if (!userID) { - ctx.log.info('could not get parent comment user id'); + const parentAuthor = get(parentComment, 'user', null); + if (!parentAuthor) { + ctx.log.info('could not get parent author'); return; } - // Pull out the author of the new comment. + // Pull out the author of the new comment. This was outputted from Mongo, so + // we have to pull it out of the `author_id` field. const authorID = get(comment, 'author_id'); // Check to see if this is yourself replying to yourself, if that's the case // don't send a notification. - if (userID === authorID) { + if (parentAuthor.id === authorID) { ctx.log.info('user id of parent comment is the same as the new comment'); return; } // Check to see if this user is ignoring the user who replied to their // comment. - if (map(get(comment, 'user.ignoredUsers', []), 'id').indexOf(authorID)) { - ctx.log.info('parent user has ignored the author of the new comment'); + const ignoredUsers = map(get(parentAuthor, 'ignoredUsers', []), 'id'); + if (ignoredUsers.includes(authorID)) { + ctx.log.info( + { parentAuthorID: parentAuthor.id, authorID }, + 'parent user has ignored the author of the new comment' + ); return; } // The user does have notifications for replied comments enabled, queue the // notification to be sent. - return { userID, date: comment.created_at, context: comment.id }; + return { + userID: parentAuthor.id, + date: comment.created_at, + context: comment.id, + }; }; const hydrate = async (ctx, category, context) => { @@ -133,7 +157,7 @@ const commentAcceptedHandleAdapter = (ctx, comment) => { } // Delegate to the handle function. - return handle(ctx, comment); + return commentAddedHandler(ctx, comment); }; module.exports = { @@ -155,7 +179,7 @@ module.exports = { translations: path.join(__dirname, 'translations.yml'), notifications: [ { - handle, + handle: commentAddedHandler, category: 'reply', event: 'commentAdded', hydrate, diff --git a/plugins/talk-plugin-notifications/server/NotificationManager.js b/plugins/talk-plugin-notifications/server/NotificationManager.js index eb90dd48b..63af8efdb 100644 --- a/plugins/talk-plugin-notifications/server/NotificationManager.js +++ b/plugins/talk-plugin-notifications/server/NotificationManager.js @@ -32,7 +32,10 @@ const handleHandlers = (ctx, handlers, ...args) => // Attempt to create a notification out of it. const notification = await handle(ctx, ...args); if (!notification) { - ctx.log.info('no notification deemed by event handler'); + ctx.log.info( + { category, event }, + 'no notification deemed by event handler' + ); return; } diff --git a/plugins/talk-plugin-notifications/server/util.js b/plugins/talk-plugin-notifications/server/util.js index 7198df1e1..bbebef4b2 100644 --- a/plugins/talk-plugin-notifications/server/util.js +++ b/plugins/talk-plugin-notifications/server/util.js @@ -3,7 +3,7 @@ const getOrganizationName = async ctx => { const { loaders: { Settings } } = ctx; // Get the settings. - const { organizationName = null } = await Settings.load('organizationName'); + const { organizationName = null } = await Settings.select('organizationName'); return organizationName; }; diff --git a/plugins/talk-plugin-profile-data/README.md b/plugins/talk-plugin-profile-data/README.md index 1d019a79b..c4fcbb456 100644 --- a/plugins/talk-plugin-profile-data/README.md +++ b/plugins/talk-plugin-profile-data/README.md @@ -21,4 +21,13 @@ that contains a download link. Only one link can be generated every 7 days, and the link will be valid for 24 hours. The downloaded zip file will contain all the users comments in a CSV format -including those that have been rejected, withheld, or still in premod. +including those that have been rejected, withheld, or still in pre-moderation. + +## GDPR Compliance + +In order to facilitate compliance with the +[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you +should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines. This +plugin can work with its client plugin disabled and then directly integrated +with existing workflows for an organization of any size through use of the API +that this plugin provides. 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..f0e7be8f3 --- /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')} + {deletionScheduledOn}. +

    +

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

    +
    + +
    +
    + ); + } +} + +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..e69de29bb 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..179fa0193 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +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_cancelled')); + } 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..26087c534 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.css @@ -0,0 +1,45 @@ +@custom-media --small-viewport (min-width: 425px); + +.dialog { + width: calc(100% - 50px); + 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); + + top: 10px; + font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif; + font-size: 14px; + border-radius: 4px; + padding: 20px; + + @media (--small-viewport) { + width: 380px; + } +} + +.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..584fbb1b8 --- /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, + 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')} +

    +
      +
    • + + {t('delete_request.step_0.item_1')} +
    • +
    • + + {t('delete_request.step_0.item_2')} +
    • +
    • + + {t('delete_request.step_0.item_3')} +
    • +
    +
    + + +
    +
    +); + +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 ? ( -