diff --git a/.gitignore b/.gitignore index 1235e0db0..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,9 +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 @@ -66,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/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-settings b/bin/cli-settings index 7f449e0d5..72f42672d 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -4,7 +4,8 @@ const util = require('./util'); const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); -const SettingsService = require('../services/settings'); +const Settings = require('../services/settings'); +const cache = require('../services/cache'); // Register the shutdown criteria. util.onshutdown([() => mongoose.disconnect()]); @@ -14,9 +15,12 @@ util.onshutdown([() => mongoose.disconnect()]); */ async function changeOrgName() { try { - let settings = await SettingsService.retrieve(); + await cache.init(); - let { organizationName } = await inquirer.prompt([ + // Get the original settings. + const settings = await Settings.retrieve('organizationName'); + + const { organizationName } = await inquirer.prompt([ { name: 'organizationName', message: 'Organization Name', @@ -25,9 +29,8 @@ async function changeOrgName() { ]); if (settings.organizationName !== organizationName) { - settings.organizationName = organizationName; - - await SettingsService.update(settings); + // Set the organization name if there was a mutation to it. + await Settings.update({ organizationName }); console.log('Settings were updated.'); } else { @@ -36,9 +39,9 @@ async function changeOrgName() { } catch (err) { console.error(err); util.shutdown(1); + } finally { + util.shutdown(); } - - util.shutdown(); } //============================================================================== diff --git a/bin/cli-users b/bin/cli-users index cfcbcd13a..a01980a20 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -287,8 +287,15 @@ async function createUser() { const { email, username, password, role } = answers; + const ctx = Context.forSystem(); + // Create the user. - const user = await UsersService.createLocalUser(email, password, username); + const user = await UsersService.createLocalUser( + ctx, + email, + password, + username + ); // Set the role. await UsersService.setRole(user.id, role); 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/AppRouter.js b/client/coral-admin/src/AppRouter.js index 28255aeb5..360ceaa93 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -10,6 +10,7 @@ import Configure from 'routes/Configure'; import StreamSettings from './routes/Configure/containers/StreamSettings'; import ModerationSettings from './routes/Configure/containers/ModerationSettings'; import TechSettings from './routes/Configure/containers/TechSettings'; +import OrganizationSettings from './routes/Configure/containers/OrganizationSettings'; import { ModerationLayout, Moderation } from 'routes/Moderation'; @@ -25,6 +26,7 @@ const routes = ( + diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index e45e3f095..5dabc5b6f 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -96,8 +96,6 @@ class UserDetail extends React.Component { bulkReject, } = this.props; - console.log(rejectedComments, totalComments); - // if totalComments is 0, you're dividing by zero let rejectedPercent = rejectedComments / totalComments * 100; diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 95595c265..3874e6c94 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -291,5 +291,36 @@ export default { }, }, }), + SetCommentStatus: ({ variables: { status } }) => ({ + 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/reducers/install.js b/client/coral-admin/src/reducers/install.js index c0a694977..8d96b9b93 100644 --- a/client/coral-admin/src/reducers/install.js +++ b/client/coral-admin/src/reducers/install.js @@ -5,6 +5,7 @@ const initialState = { isLoading: false, data: { settings: { + organizationContactEmail: '', organizationName: '', domains: { whitelist: [], @@ -19,6 +20,7 @@ const initialState = { }, errors: { organizationName: '', + organizationContactEmail: '', username: '', email: '', password: '', diff --git a/client/coral-admin/src/routes/Configure/components/Configure.js b/client/coral-admin/src/routes/Configure/components/Configure.js index 4d88f8420..bcd48c21c 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.js +++ b/client/coral-admin/src/routes/Configure/components/Configure.js @@ -8,7 +8,14 @@ import SaveChangesDialog from './SaveChangesDialog'; class Configure extends React.Component { render() { - const { canSave, currentUser, root, savePending, settings } = this.props; + const { + canSave, + currentUser, + root, + savePending, + settings, + clearPending, + } = this.props; if (!can(currentUser, 'UPDATE_CONFIG')) { return

{t('configure.access_message')}

; @@ -17,6 +24,9 @@ class Configure extends React.Component { const passProps = { root, settings, + savePending, + clearPending, + canSave, }; return ( @@ -41,6 +51,9 @@ class Configure extends React.Component { {t('configure.tech_settings')} + + {t('configure.organization_information')} +
{canSave ? ( @@ -81,6 +94,7 @@ Configure.propTypes = { children: PropTypes.node.isRequired, saveDialog: PropTypes.bool, hideSaveDialog: PropTypes.func.isRequired, + clearPending: PropTypes.func.isRequired, }; export default Configure; diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.css b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.css new file mode 100644 index 000000000..2468496b7 --- /dev/null +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.css @@ -0,0 +1,87 @@ +.label { + display: block; +} + +.detailList { + padding: 0; + margin: 0; +} + +.detailLabel { + color: #000; + font-size: 1.1em; + font-weight: bold; + display: block; + margin-bottom: 4px; +} + +.detailValue { + padding: 6px 0; + border: solid 1px transparent; + display: block; + font-size: 1.1em; + border-radius: 2px; + color: #424242; + box-sizing: border-box; + min-width: 300px; +} + +.editable { + padding-left: 10px; + padding-right: 10px; + border-color: grey; +} + +.detailItem { + margin-bottom: 16px; + list-style: none; +} + +.button, .button:disabled { + background: white; + border: solid 1px grey; +} + +.actionBox { + flex-grow: 0; +} + +.cancelButton { + padding: 10px; + display: block; + color: #4f5c67; + font-weight: 500; + + &:hover { + cursor: pointer; + } +} + +.changedSave { + background-color: #00796B; + color: white; +} + +.errorList { + list-style: none; + padding: 0; + margin: 0; +} + +.errorItem { + padding: 5px 10px; + margin-bottom: 20px; + color: #b71c1c; + border-radius: 2px; + display: inline-block; + background: #F9D3CE; +} + +.container { + display: flex; +} + +.content { + flex-grow: 1; + padding-right: 40px; +} \ No newline at end of file diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js new file mode 100644 index 000000000..3505ef7b5 --- /dev/null +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js @@ -0,0 +1,185 @@ +import React from 'react'; +import cn from 'classnames'; +import { Button } from 'coral-ui'; +import PropTypes from 'prop-types'; +import styles from './OrganizationSettings.css'; +import Slot from 'coral-framework/components/Slot'; +import t from 'coral-framework/services/i18n'; +import ConfigurePage from './ConfigurePage'; +import ConfigureCard from 'coral-framework/components/ConfigureCard'; +import validate from 'coral-framework/helpers/validate'; +import errorMsj from 'coral-framework/helpers/error'; + +class OrganizationSettings extends React.Component { + state = { editing: false, errors: [] }; + + addError = err => { + if (this.state.errors.indexOf(err) === -1) { + this.setState(({ errors }) => ({ + errors: errors.concat(err), + })); + } + }; + + removeError = err => { + this.setState(({ errors }) => ({ + errors: errors.filter(i => i !== err), + })); + }; + + toggleEditing = () => { + this.setState(({ editing }) => ({ + editing: !editing, + })); + }; + + disableEditing = () => { + this.setState(() => ({ + editing: false, + })); + }; + + updateName = event => { + const updater = { organizationName: { $set: event.target.value } }; + this.props.updatePending({ updater }); + }; + + updateEmail = event => { + let error = null; + const email = event.target.value; + + // Add a blocker error + if (!validate.email(email)) { + error = true; + this.addError('email'); + } else { + this.removeError('email'); + } + + const updater = { organizationContactEmail: { $set: email } }; + const errorUpdater = { organizationContactEmail: { $set: error } }; + + this.props.updatePending({ updater, errorUpdater }); + }; + + cancelEditing = () => { + this.disableEditing(); + this.props.clearPending(); + }; + + save = async () => { + await this.props.savePending(); + this.disableEditing(); + }; + displayErrors = (errors = []) => ( +
    + {errors.map((errKey, i) => ( +
  • + {errorMsj[errKey]} +
  • + ))} +
+ ); + + render() { + const { settings, slotPassthrough, canSave } = this.props; + const hasErrors = this.state.errors.length; + return ( + +

{t('configure.organization_info_copy')}

+

{t('configure.organization_info_copy_2')}

+ +
+
+ {this.displayErrors(this.state.errors)} +
    +
  • + + +
  • +
  • + + +
  • +
+
+ {!this.state.editing ? ( +
+ +
+ ) : ( +
+ {canSave && !hasErrors ? ( + + ) : ( + + )} + + {t('cancel')} + +
+ )} +
+
+ +
+ ); + } +} + +OrganizationSettings.propTypes = { + savePending: PropTypes.func.isRequired, + clearPending: PropTypes.func.isRequired, + updatePending: PropTypes.func.isRequired, + errors: PropTypes.object.isRequired, + settings: PropTypes.object.isRequired, + slotPassthrough: PropTypes.object.isRequired, + canSave: PropTypes.bool.isRequired, +}; + +export default OrganizationSettings; diff --git a/client/coral-admin/src/routes/Configure/containers/Configure.js b/client/coral-admin/src/routes/Configure/containers/Configure.js index 9f980d31b..3d2789974 100644 --- a/client/coral-admin/src/routes/Configure/containers/Configure.js +++ b/client/coral-admin/src/routes/Configure/containers/Configure.js @@ -16,10 +16,12 @@ import { hideSaveDialog, } from '../../../actions/configure'; import Configure from '../components/Configure'; +import OrganizationSettings from './OrganizationSettings'; import { withRouter } from 'react-router'; class ConfigureContainer extends React.Component { - state = { nextRoute: '' }; + nextRoute = ''; + unregisterLeaveHook = null; savePending = async () => { await this.props.updateSettings(this.props.pending); @@ -39,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 @@ -58,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() { @@ -83,18 +100,21 @@ class ConfigureContainer extends React.Component { return ; } + const activeSection = this.props.routes[3].path; + return ( {this.props.children} @@ -110,10 +130,12 @@ const withConfigureQuery = withQuery( ...${getDefinitionName(StreamSettings.fragments.settings)} ...${getDefinitionName(TechSettings.fragments.settings)} ...${getDefinitionName(ModerationSettings.fragments.settings)} + ...${getDefinitionName(OrganizationSettings.fragments.settings)} } ...${getDefinitionName(StreamSettings.fragments.root)} ...${getDefinitionName(TechSettings.fragments.root)} ...${getDefinitionName(ModerationSettings.fragments.root)} + ...${getDefinitionName(OrganizationSettings.fragments.root)} } ${StreamSettings.fragments.root} ${StreamSettings.fragments.settings} @@ -121,6 +143,8 @@ const withConfigureQuery = withQuery( ${TechSettings.fragments.settings} ${ModerationSettings.fragments.root} ${ModerationSettings.fragments.settings} + ${OrganizationSettings.fragments.root} + ${OrganizationSettings.fragments.settings} `, { options: () => ({ diff --git a/client/coral-admin/src/routes/Configure/containers/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/containers/OrganizationSettings.js new file mode 100644 index 000000000..79ab70f69 --- /dev/null +++ b/client/coral-admin/src/routes/Configure/containers/OrganizationSettings.js @@ -0,0 +1,53 @@ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { compose, gql } from 'react-apollo'; +import OrganizationSettings from '../components/OrganizationSettings'; +import withFragments from 'coral-framework/hocs/withFragments'; +import { getSlotFragmentSpreads } from 'coral-framework/utils'; +import { updatePending } from '../../../actions/configure'; +import { mapProps } from 'recompose'; + +const slots = ['adminOrganizationSettings']; + +const mapStateToProps = state => ({ + errors: state.configure.errors, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + updatePending, + }, + dispatch + ); + +export default compose( + withFragments({ + root: gql` + fragment TalkAdmin_OrganizationSettings_root on RootQuery { + __typename + ${getSlotFragmentSpreads(slots, 'root')} + } + `, + settings: gql` + fragment TalkAdmin_OrganizationSettings_settings on Settings { + organizationName + organizationContactEmail + ${getSlotFragmentSpreads(slots, 'settings')} + } + `, + }), + connect(mapStateToProps, mapDispatchToProps), + mapProps(({ root, settings, updatePending, errors, ...rest }) => ({ + slotPassthrough: { + root, + settings, + updatePending, + errors, + }, + updatePending, + settings, + errors, + ...rest, + })) +)(OrganizationSettings); diff --git a/client/coral-admin/src/routes/Install/components/Install.js b/client/coral-admin/src/routes/Install/components/Install.js index 14afc12f5..4e0024102 100644 --- a/client/coral-admin/src/routes/Install/components/Install.js +++ b/client/coral-admin/src/routes/Install/components/Install.js @@ -1,15 +1,16 @@ -import React, { Component } from 'react'; +import React from 'react'; import styles from './Install.css'; import { Wizard, WizardNav } from 'coral-ui'; import Layout from 'coral-admin/src/components/Layout'; +import PropTypes from 'prop-types'; import InitialStep from './Steps/InitialStep'; -import AddOrganizationName from './Steps/AddOrganizationName'; +import OrganizationDetails from './Steps/OrganizationDetails'; import CreateYourAccount from './Steps/CreateYourAccount'; import PermittedDomainsStep from './Steps/PermittedDomainsStep'; import FinalStep from './Steps/FinalStep'; -export default class Install extends Component { +class Install extends React.Component { handleDomainsChange = value => { this.props.updatePermittedDomains(value); }; @@ -55,7 +56,7 @@ export default class Install extends Component { goToStep={this.props.goToStep} > - { showErrors={install.showErrors} errorMsg={install.errors.organizationName} /> + + +
); 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/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js index 0a7a1951c..17d3132ea 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -372,6 +372,7 @@ const slots = [ 'streamTabsPrepend', 'streamTabPanes', 'streamFilter', + 'stream', ]; const fragments = { 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/ConfigureCard.js b/client/coral-framework/components/ConfigureCard.js index 703733a7d..7a2872318 100644 --- a/client/coral-framework/components/ConfigureCard.js +++ b/client/coral-framework/components/ConfigureCard.js @@ -43,7 +43,7 @@ const ConfigureCard = ({ ); ConfigureCard.propTypes = { - title: PropTypes.string.isRequired, + title: PropTypes.string, className: PropTypes.string, onCheckbox: PropTypes.func, checked: PropTypes.bool, diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js index a62fd6d92..778214b8b 100644 --- a/client/coral-framework/graphql/fragments.js +++ b/client/coral-framework/graphql/fragments.js @@ -25,6 +25,9 @@ export default { 'UnsuspendUserResponse', 'UpdateAssetSettingsResponse', 'UpdateAssetStatusResponse', - 'UpdateSettingsResponse' + 'UpdateSettingsResponse', + 'ChangePasswordResponse', + 'UpdateEmailAddressResponse', + 'AttachLocalAuthResponse' ), }; diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index bd31be630..995ca1720 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 { @@ -623,6 +592,27 @@ export const withUpdateSettings = withMutation( } ); +export const withChangePassword = withMutation( + gql` + mutation ChangePassword($input: ChangePasswordInput!) { + changePassword(input: $input) { + ...ChangePasswordResponse + } + } + `, + { + props: ({ mutate }) => ({ + changePassword: input => { + return mutate({ + variables: { + input, + }, + }); + }, + }), + } +); + export const withUpdateAssetSettings = withMutation( gql` mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) { diff --git a/client/coral-framework/helpers/error.js b/client/coral-framework/helpers/error.js index 18c365de1..a041bde4e 100644 --- a/client/coral-framework/helpers/error.js +++ b/client/coral-framework/helpers/error.js @@ -6,4 +6,5 @@ export default { username: t('error.username'), confirmPassword: t('error.confirm_password'), organizationName: t('error.organization_name'), + organizationContactEmail: t('error.organization_contact_email'), }; diff --git a/client/coral-framework/helpers/validate.js b/client/coral-framework/helpers/validate.js index 849e00e7a..26c1d8a99 100644 --- a/client/coral-framework/helpers/validate.js +++ b/client/coral-framework/helpers/validate.js @@ -4,4 +4,5 @@ export default { confirmPassword: () => true, username: username => /^[a-zA-Z0-9_]+$/.test(username), organizationName: org => /^[a-zA-Z0-9_ ]+$/.test(org), + organizationContactEmail: email => /^.+@.+\..+$/.test(email), }; diff --git a/client/coral-framework/services/i18n.js b/client/coral-framework/services/i18n.js index e1c3ab99e..03a0b4712 100644 --- a/client/coral-framework/services/i18n.js +++ b/client/coral-framework/services/i18n.js @@ -9,6 +9,7 @@ import 'moment/locale/da'; import 'moment/locale/de'; import 'moment/locale/es'; import 'moment/locale/fr'; +import 'moment/locale/nl'; import 'moment/locale/pt-br'; import { createStorage } from 'coral-framework/services/storage'; @@ -18,10 +19,10 @@ import daTA from 'timeago.js/locales/da'; import deTA from 'timeago.js/locales/de'; import esTA from 'timeago.js/locales/es'; import frTA from 'timeago.js/locales/fr'; +import nlTA from 'timeago.js/locales/nl'; import pt_BRTA from 'timeago.js/locales/pt_BR'; import zh_CNTA from 'timeago.js/locales/zh_CN'; import zh_TWTA from 'timeago.js/locales/zh_TW'; -import nl from 'timeago.js/locales/nl'; import ar from '../../../locales/ar.yml'; import en from '../../../locales/en.yml'; @@ -29,10 +30,10 @@ import da from '../../../locales/da.yml'; import de from '../../../locales/de.yml'; import es from '../../../locales/es.yml'; import fr from '../../../locales/fr.yml'; +import nl_NL from '../../../locales/nl_NL.yml'; import pt_BR from '../../../locales/pt_BR.yml'; import zh_CN from '../../../locales/zh_CN.yml'; import zh_TW from '../../../locales/zh_TW.yml'; -import nl_NL from '../../../locales/nl_NL.yml'; const defaultLanguage = process.env.TALK_DEFAULT_LANG; const translations = { @@ -112,10 +113,10 @@ export function setupTranslations() { ta.register('da', daTA); ta.register('de', deTA); ta.register('fr', frTA); + ta.register('nl_NL', nlTA); ta.register('pt_BR', pt_BRTA); ta.register('zh_CN', zh_CNTA); ta.register('zh_TW', zh_TWTA); - ta.register('nl_NL', nl); timeagoInstance = ta(); } 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 f50fca335..610557542 100644 --- a/client/coral-framework/utils/user.js +++ b/client/coral-framework/utils/user.js @@ -1,4 +1,5 @@ import get from 'lodash/get'; +import moment from 'moment'; /** * getReliability @@ -33,3 +34,18 @@ export const isSuspended = user => { export const isBanned = user => { return get(user, 'state.status.banned.status'); }; + +/** + * 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/Icon.css b/client/coral-ui/components/Icon.css index 1a118257a..72708e863 100644 --- a/client/coral-ui/components/Icon.css +++ b/client/coral-ui/components/Icon.css @@ -1,5 +1,5 @@ .root { - vertical-align: middle; + vertical-align: sub; font-size: inherit; } 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-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-08-gdpr.md b/docs/source/03-08-gdpr.md new file mode 100644 index 000000000..9b1050454 --- /dev/null +++ b/docs/source/03-08-gdpr.md @@ -0,0 +1,53 @@ +--- +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 acccount**: 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. + +## 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/_data/plugins.yml b/docs/source/_data/plugins.yml index 4c3621fcb..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: @@ -81,6 +88,11 @@ description: Shows a Link button on comments for direct-linking to a comment. tags: - default +- name: talk-plugin-profile-data + description: Enables users to manage their own data within Talk. + tags: + - default + - gdpr - name: talk-plugin-remember-sort description: Remembers the sort selection made by a user. - name: talk-plugin-respect diff --git a/docs/source/api/slots.md b/docs/source/api/slots.md index 4ac47a516..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" ] } ``` @@ -81,6 +80,7 @@ You won't have to use this to build plugins, but it's helpful to find where to e * `adminCommentMoreDetails` * `adminCommentLabels` * `adminModerationSettings` +* `adminOrganizationSettings` * `adminStreamSettings` * `adminTechSettings` * `adminCommentInfoBar` 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/plugin/talk-plugin-profile-data.md b/docs/source/plugin/talk-plugin-profile-data.md new file mode 120000 index 000000000..022085ed6 --- /dev/null +++ b/docs/source/plugin/talk-plugin-profile-data.md @@ -0,0 +1 @@ +../../../plugins/talk-plugin-profile-data/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/graph/connectors.js b/graph/connectors.js index 3ba71fbfb..3ad3024ea 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -10,6 +10,9 @@ const secrets = require('../secrets'); // Errors. const errors = require('../errors'); +// URLs. +const url = require('../url'); + // Graph. const { getBroker } = require('./subscriptions/broker'); const { getPubsub } = require('./subscriptions/pubsub'); @@ -58,6 +61,7 @@ const defaultConnectors = { errors, config, secrets, + url, models: { Action, Asset, diff --git a/graph/context.js b/graph/context.js index e9ac7ea61..9c356f0e7 100644 --- a/graph/context.js +++ b/graph/context.js @@ -137,6 +137,13 @@ class Context { ); } + /** + * masqueradeAs will allow a given context to be copied to a new user. + */ + masqueradeAs(user) { + return new Context(merge({}, this, { user })); + } + /** * forSystem returns a system context object that can be used for internal * operations. diff --git a/graph/mutators/user.js b/graph/mutators/user.js index e0312533f..8657905d9 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -1,5 +1,5 @@ const { ErrNotFound, ErrNotAuthorized } = require('../../errors'); -const UsersService = require('../../services/users'); +const Users = require('../../services/users'); const migrationHelpers = require('../../services/migration/helpers'); const { CHANGE_USERNAME, @@ -8,11 +8,12 @@ const { SET_USER_BAN_STATUS, SET_USER_SUSPENSION_STATUS, UPDATE_USER_ROLES, - DELETE_USER, + DELETE_OTHER_USER, + CHANGE_PASSWORD, } = require('../../perms/constants'); const setUserUsernameStatus = async (ctx, id, status) => { - const user = await UsersService.setUsernameStatus(id, status, ctx.user.id); + const user = await Users.setUsernameStatus(id, status, ctx.user.id); if (status === 'REJECTED') { ctx.pubsub.publish('usernameRejected', user); } else if (status === 'APPROVED') { @@ -21,12 +22,7 @@ const setUserUsernameStatus = async (ctx, id, status) => { }; const setUserBanStatus = async (ctx, id, status = false, message = null) => { - const user = await UsersService.setBanStatus( - id, - status, - ctx.user.id, - message - ); + const user = await Users.setBanStatus(id, status, ctx.user.id, message); if (user.banned) { ctx.pubsub.publish('userBanned', user); } @@ -38,38 +34,33 @@ const setUserSuspensionStatus = async ( until = null, message = null ) => { - const user = await UsersService.setSuspensionStatus( - id, - until, - ctx.user.id, - message - ); + const user = await Users.setSuspensionStatus(id, until, ctx.user.id, message); if (user.suspended) { ctx.pubsub.publish('userSuspended', user); } }; const ignoreUser = ({ user }, userToIgnore) => { - return UsersService.ignoreUsers(user.id, [userToIgnore.id]); + return Users.ignoreUsers(user.id, [userToIgnore.id]); }; const stopIgnoringUser = ({ user }, userToStopIgnoring) => { - return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]); + return Users.stopIgnoringUsers(user.id, [userToStopIgnoring.id]); }; const changeUsername = async (ctx, id, username) => { - const user = await UsersService.changeUsername(id, username, ctx.user.id); + const user = await Users.changeUsername(id, username, ctx.user.id); const previousUsername = ctx.user.username; ctx.pubsub.publish('usernameChanged', { previousUsername, user }); return user; }; const setUsername = async (ctx, id, username) => { - return UsersService.setUsername(id, username, ctx.user.id); + return Users.setUsername(id, username, ctx.user.id); }; const setRole = (ctx, id, role) => { - return UsersService.setRole(id, role); + return Users.setRole(id, role); }; /** @@ -102,7 +93,7 @@ const delUser = async (ctx, id) => { updateBatchSize: 10000, }); - // Remove all actions against comments. + // Remove all actions against this users comments. await transformSingleWithCursor( Action.collection.find({ user_id: user.id, item_type: 'COMMENTS' }), actionDecrTransformer, @@ -121,38 +112,86 @@ 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(); }; +const changeUserPassword = async (ctx, oldPassword, newPassword) => { + const { + user, + loaders: { Settings }, + connectors: { services: { I18n } }, + } = ctx; + + // Verify the old password. + const validPassword = await user.verifyPassword(oldPassword); + if (!validPassword) { + throw new ErrNotAuthorized(); + } + + // 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([ + 'organizationName', + 'organizationContactEmail', + ]); + + // Send the password change email. + await Users.sendEmail(user, { + template: 'plain', + locals: { + body: I18n.t('email.password_change.body', organizationContactEmail), + }, + subject: I18n.t('email.password_change.subject', organizationName), + }); +}; + module.exports = ctx => { let mutators = { User: { @@ -165,6 +204,7 @@ module.exports = ctx => { setUsername: () => Promise.reject(new ErrNotAuthorized()), stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()), del: () => Promise.reject(new ErrNotAuthorized()), + changePassword: () => Promise.reject(new ErrNotAuthorized()), }, }; @@ -201,9 +241,14 @@ 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); } + + if (ctx.user.can(CHANGE_PASSWORD)) { + mutators.User.changePassword = ({ oldPassword, newPassword }) => + changeUserPassword(ctx, oldPassword, newPassword); + } } return mutators; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index b174dd5ae..e562c062c 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,8 +1,10 @@ +const { URL } = require('url'); const { property } = require('lodash'); const { SEARCH_ACTIONS, SEARCH_COMMENT_STATUS_HISTORY, VIEW_BODY_HISTORY, + VIEW_COMMENT_DELETED_AT, } = require('../../perms/constants'); const { decorateWithTags, @@ -22,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! @@ -63,6 +67,16 @@ const Comment = { editableUntil: editableUntil, }; }, + async url(comment, args, { loaders: { Assets } }) { + const asset = await Assets.getByID.load(comment.asset_id); + if (!asset) { + return null; + } + + const assetURL = new URL(asset.url); + assetURL.searchParams.set('commentId', comment.id); + return assetURL.href; + }, }; // Decorate the Comment type resolver with a tags field. @@ -72,6 +86,7 @@ decorateWithTags(Comment); decorateWithPermissionCheck(Comment, { actions: [SEARCH_ACTIONS], status_history: [SEARCH_COMMENT_STATUS_HISTORY], + deleted_at: [VIEW_COMMENT_DELETED_AT], }); // Protect privileged fields. diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 2838f0f99..d2c2965e0 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -139,6 +139,9 @@ const RootMutation = { delUser: async (_, { id }, { mutators: { User } }) => { await User.del(id); }, + changePassword: async (_, { input }, { mutators: { User } }) => { + await User.changePassword(input); + }, }; module.exports = RootMutation; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index c1e7b9ecb..b5c193e3d 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -503,7 +503,7 @@ type Comment { id: ID! # The actual comment data. - body: String! + body: String # The body history of the comment. Requires the `ADMIN` or `MODERATOR` role or # the author. @@ -537,6 +537,9 @@ type Comment { # The status history of the comment. Requires the `ADMIN` or `MODERATOR` role. status_history: [CommentStatusHistory!] + # The date that the comment was deleted at if it was. + deleted_at: Date + # The time when the comment was created created_at: Date! @@ -548,6 +551,9 @@ type Comment { # Indicates if it has a parent hasParent: Boolean + + # url is the permalink to this particular Comment on the Asset. + url: String } # CommentConnection represents a paginable subset of a comment list. @@ -835,6 +841,9 @@ type Settings { # organizationName is the name of the organization. organizationName: String + # organizationContactEmail is the email of the organization. + organizationContactEmail: String + # wordlist will return a given list of words. wordlist: Wordlist @@ -1291,6 +1300,9 @@ input UpdateSettingsInput { # organizationName is the name of the organization. organizationName: String + # organizationContactEmail is the email of the organization. + organizationContactEmail: 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 @@ -1433,6 +1445,21 @@ type DelUserResponse implements Response { errors: [UserError!] } +input ChangePasswordInput { + # oldPassword is the previous password set on the account. An incorrect + # password here will result in an unauthorized error being thrown. + oldPassword: String! + + # newPassword is the password we're changing it to. + newPassword: String! +} + +type ChangePasswordResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + # All mutations for the application are defined on this object. type RootMutation { @@ -1533,6 +1560,10 @@ type RootMutation { # delUser will delete the user with the specified id. delUser(id: ID!): DelUserResponse + + # changePassword allows the current user to change their password that have an + # associated local user account. + changePassword(input: ChangePasswordInput!): ChangePasswordResponse } type UsernameChangedPayload { diff --git a/locales/ar.yml b/locales/ar.yml index 98f6cc54e..852b9ac1b 100644 --- a/locales/ar.yml +++ b/locales/ar.yml @@ -466,6 +466,7 @@ ar: username: "Username" password: "Password" confirm_password: "Confirm Password" + organization_contact_email: "Organization Contact Email" save: "Save" permitted_domains: title: "Permitted domains" diff --git a/locales/da.yml b/locales/da.yml index 0afe4c5ce..4bcb3bb1d 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -459,6 +459,7 @@ da: username: "Brugernavn" password: "Kodeord" confirm_password: "Bekræft kodeord" + organization_contact_email: "Organization Contact Email" save: "Gem" permitted_domains: title: "Tilladte domæner" diff --git a/locales/de.yml b/locales/de.yml index 6f7a1a66e..d2b247fbc 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -458,6 +458,7 @@ de: username: "Nutzername" password: "Passwort" confirm_password: "Passwort bestätigen" + organization_contact_email: "Organization Contact Email" save: "Speichern" permitted_domains: title: "Zugelassene Domains" diff --git a/locales/en.yml b/locales/en.yml index 4a832648c..3d2fe88c7 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -20,11 +20,13 @@ en: bio_offensive: "This bio is offensive" cancel: "Cancel" confirm_email: - click_to_confirm: "Click below to confirm your email address" + email_confirmation: "Email Confirmation" + click_to_confirm: "Click below to confirm your email address." confirm: "Confirm" password_reset: mail_sent: 'If you have a registered account, a password reset link was sent to that email' set_new_password: "Change Your Password" + change_password_help: "Please enter a new password to use to login. Make it secure!" new_password: "New Password" new_password_help: "Password must be at least 8 characters" confirm_new_password: "Confirm New Password" @@ -121,9 +123,10 @@ 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." 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" edit_comment_timeframe_heading: "Edit Comment Timeframe" edit_comment_timeframe_text_pre: "Commenters will have" edit_comment_timeframe_text_post: "seconds to edit their comments." @@ -149,6 +152,7 @@ en: open_stream_configuration: "This comment stream is currently open. By closing this comment stream no new comments may be submitted and all previous comments will still be displayed." require_email_verification: "Require Email Verification" require_email_verification_text: "New Users must verify their email before commenting" + save: Save save_changes: "Save Changes" shortcuts: Shortcuts sign_out: "Sign Out" @@ -158,6 +162,12 @@ 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_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 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" title: "Configure Comment Stream" weeks: Weeks wordlist: "Banned Words" @@ -208,6 +218,10 @@ 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: @@ -223,7 +237,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." @@ -236,12 +250,15 @@ en: 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" username: "Usernames can contain letters numbers and _ only" unexpected: "Unexpected error occurred. Sorry!" @@ -256,6 +273,7 @@ en: comment: comment comment_is_ignored: "This comment is hidden because you ignored this user." comment_is_rejected: "You have rejected this comment." + comment_is_deleted: "This comment was deleted." comment_is_hidden: "This comment is not available." comments: comments configure_stream: "Configure" @@ -427,7 +445,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." @@ -475,6 +493,7 @@ en: username: "Username" password: "Password" confirm_password: "Confirm Password" + organization_contact_email: "Organization Contact Email" save: "Save" permitted_domains: title: "Permitted domains" diff --git a/locales/es.yml b/locales/es.yml index a6d35ef0d..ff2777c41 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -121,9 +121,10 @@ 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" edit_comment_timeframe_heading: "Periodo de Tiempo para Edición del Comentario" edit_comment_timeframe_text_pre: "Los comentaristas tendrán" edit_comment_timeframe_text_post: "segundos para editar sus comentarios." @@ -149,6 +150,7 @@ es: open_stream_configuration: "Este hilo de comentarios está abierto. Al cerrarlo no se podrán publicar nuevos comentarios pero todos los comentarios anteriores aún serán mostrados." require_email_verification: "Necesita confirmación su correo" require_email_verification_text: "Nuevos usuarios deben confirmar sus correos antes de comentar" + save: Guardar save_changes: "Guardar Cambios" shortcuts: Atajos sign_out: "Desconectar" @@ -158,6 +160,12 @@ es: suspect_word_title: "Lista de palabras sospechosas" suspect_word_text: "Comentarios que contengan estas palabras o frases, considerando mayusculas y minúsculas, serán automáticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agregarla. O pegar una lista de palabras separadas por coma." tech_settings: "Configuración Técnica" + organization_information: "Información de la Organización" + organization_details: "Detalles de la Organización" + organization_info_copy: "Nosotros usamos esta información en las notificaciones de email generadas por Talk. Esto conecta los mensajes de tu organización, y provee una forma para que los usuarios se comuniquen si tienen un inconveniente con su cuenta." + organization_info_copy_2: "Recomendamos crear un email genérico (ej: community@yournewsroom.com) for this purpose. Esto significa que puede permanecer consistente con el tiempo y no expone un nombre que los usuarios puedan atacar si su cuenta fue bloqueada." + organization_name: "Nombre de la Organización" + organization_contact_email: "Email de la Organización" title: "Configurar los comentarios" weeks: Semanas wordlist: "Palabras Suspendidas" @@ -208,6 +216,7 @@ 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: @@ -235,12 +244,14 @@ es: ALREADY_EXISTS: "El recurso ya existe" INVALID_ASSET_URL: "La URL del articulo no es valida" 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." email_not_verified: "Correo {0} no confirmado." email_password: "Correo y/o contraseña incorrecta." organization_name: "El nombre de la organización debe contener letras y/o números." + organization_contact_email: "El email de la organización no es válido." 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" @@ -468,6 +479,7 @@ es: username: "Nombre de Usuario" password: "Contraseña" confirm_password: "Confirmar Contraseña" + organization_contact_email: "Organización: Email de contacto" save: "Guardar" permitted_domains: title: "Dominios permitidos" diff --git a/locales/fr.yml b/locales/fr.yml index 1b140f6ba..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" @@ -474,6 +474,7 @@ fr: username: "Nom d'utilisateur" password: "Mot de passe" confirm_password: "Confirmez Le mot de passe" + organization_contact_email: "Organization Contact Email" save: "Sauvegarder" permitted_domains: title: "Domaines autorisés" diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index d9ef119ed..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" @@ -458,6 +458,7 @@ pt_BR: username: "Nome de usuário" password: "Senha" confirm_password: "Confirme a senha" + organization_contact_email: "Organization Contact Email" save: "Salvar" permitted_domains: title: "Domínios permitidos" diff --git a/models/schema/comment.js b/models/schema/comment.js index d9586e850..a211884ff 100644 --- a/models/schema/comment.js +++ b/models/schema/comment.js @@ -58,8 +58,6 @@ const Comment = new Schema( }, body: { type: String, - required: [true, 'The body is required.'], - minlength: 2, }, body_history: [BodyHistoryItemSchema], asset_id: String, @@ -89,6 +87,12 @@ const Comment = new Schema( // Tags are added by the self or by administrators. tags: [TagLinkSchema], + // deleted_at stores the date that the given comment was deleted. + deleted_at: { + type: Date, + default: null, + }, + // Additional metadata stored on the field. metadata: { default: {}, diff --git a/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 5e226e6cb..af7932910 100644 --- a/models/schema/setting.js +++ b/models/schema/setting.js @@ -49,6 +49,9 @@ const Setting = new Schema( organizationName: { type: String, }, + organizationContactEmail: { + type: String, + }, autoCloseStream: { type: Boolean, default: false, diff --git a/package.json b/package.json index 845e2b11a..1127dd05c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "4.3.2", + "version": "4.4.0", "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", @@ -83,6 +83,7 @@ "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", @@ -97,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", @@ -218,7 +218,6 @@ "babel-plugin-dynamic-import-node": "^1.1.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", "browserstack-local": "^1.3.0", - "bunyan-debug-stream": "^1.0.8", "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 d2ebe73f1..2d4ae04cb 100644 --- a/perms/constants/mutation.js +++ b/perms/constants/mutation.js @@ -18,5 +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 73ee7ef28..8133fcd47 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -1,13 +1,33 @@ +const { get, isString } = require('lodash'); +const moment = require('moment'); const { check } = require('../utils'); const types = require('../constants'); module.exports = (user, perm) => { switch (perm) { + case types.CHANGE_PASSWORD: + // Only users with a local account where they have a password set can + // actually change their password. + return ( + user.profiles.some(({ provider }) => provider === 'local') && + 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: @@ -36,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 215641f32..d7ca5fce9 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -25,5 +25,7 @@ export { withUnbanUser, 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..5aa3063b3 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -2,23 +2,25 @@ 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.createIndex( + { + created_at: 1, + [`action_counts.${sc(reaction)}`]: 1, + }, + { + background: true, + } + ); + } const reactionPlural = pluralize(reaction); const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1); @@ -127,17 +129,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 ca17c79d8..0fbc6e658 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -1,7 +1,8 @@ { "server": [ - "talk-plugin-auth", "talk-plugin-featured-comments", + "talk-plugin-local-auth", + "talk-plugin-profile-data", "talk-plugin-respect" ], "client": [ @@ -10,9 +11,11 @@ "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", 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/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} +
+); + +ErrorMessage.propTypes = { + children: PropTypes.node, +}; + +export default ErrorMessage; 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-facebook-auth/client/components/InputField.js b/plugins/talk-plugin-facebook-auth/client/components/InputField.js new file mode 100644 index 000000000..26937d5b9 --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/components/InputField.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import styles from './InputField.css'; +import ErrorMessage from './ErrorMessage'; +import { Icon } from 'plugin-api/beta/client/components/ui'; + +const InputField = ({ + id = '', + label = '', + type = 'text', + name = '', + onChange = () => {}, + showError = true, + hasError = false, + errorMsg = '', + children, + columnDisplay = false, + showSuccess = false, + validationType = '', + icon = '', + value = '', + defaultValue = '', + disabled = false, +}) => { + const inputValue = { + ...(value ? { value } : {}), + ...(defaultValue ? { defaultValue } : {}), + }; + + return ( +
+
+ {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-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-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/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..691915c0f --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/AddEmailAddressDialog.js @@ -0,0 +1,153 @@ +import React from 'react'; +import isMatch from 'lodash/isEqual'; +import isEqualWith from 'lodash/isEqual'; +import PropTypes from 'prop-types'; +import { Dialog } from 'plugin-api/beta/client/components/ui'; +import validate from 'coral-framework/helpers/validate'; +import errorMsj from 'coral-framework/helpers/error'; +import { getErrorMessages } from 'coral-framework/utils'; +import styles from './AddEmailAddressDialog.css'; + +import AddEmailContent from './AddEmailContent'; +import VerifyEmailAddress from './VerifyEmailAddress'; +import EmailAddressAdded from './EmailAddressAdded'; + +const initialState = { + step: 0, + showErrors: false, + errors: {}, + formData: {}, +}; + +class AddEmailAddressDialog extends React.Component { + state = initialState; + validKeys = ['emailAddress', 'confirmPassword', 'confirmEmailAddress']; + + onChange = e => { + const { name, value, type } = e.target; + this.setState( + state => ({ + formData: { + ...state.formData, + [name]: value, + }, + }), + () => { + this.fieldValidation(value, type, name); + } + ); + }; + + fieldValidation = (value, type, name) => { + if (!value.length) { + this.addError({ + [name]: 'Field is required', + }); + } else if (!validate[type](value)) { + this.addError({ [name]: errorMsj[type] }); + } else { + this.removeError(name); + } + }; + + addError = err => { + this.setState(({ errors }) => ({ + errors: { ...errors, ...err }, + })); + }; + + removeError = errKey => { + this.setState(state => { + const { [errKey]: _, ...errors } = state.errors; + return { + errors, + }; + }); + }; + + hasError = err => { + return Object.keys(this.state.errors).indexOf(err) !== -1; + }; + + formHasError = () => { + const formHasErrors = !!Object.keys(this.state.errors).length; + const formIncomplete = !isEqualWith( + Object.keys(this.state.formData), + this.validKeys, + isMatch + ); + + return formHasErrors || formIncomplete; + }; + + showErrors = () => { + this.setState({ + showErrors: true, + }); + }; + + confirmChanges = async () => { + if (this.formHasError()) { + this.showErrors(); + return; + } + + const { emailAddress, confirmPassword } = this.state.formData; + const { attachLocalAuth } = this.props; + + try { + await attachLocalAuth({ + email: emailAddress, + password: confirmPassword, + }); + this.props.notify('success', 'Email Added!'); + 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, +}; + +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..4a2ce7430 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/AddEmailContent.js @@ -0,0 +1,106 @@ +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..1f1719444 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeEmailContentDialog.js @@ -0,0 +1,90 @@ +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'; + +class ChangeEmailContentDialog extends React.Component { + state = { + showError: false, + }; + + showError = () => { + this.setState({ + showError: true, + }); + }; + + confirmChanges = async e => { + e.preventDefault(); + await this.props.save(); + this.props.next(); + }; + + 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 = { + save: PropTypes.func, + next: PropTypes.func, + cancel: PropTypes.func, + onChange: PropTypes.func, + formData: PropTypes.object, + email: PropTypes.string, +}; + +export default ChangeEmailContentDialog; diff --git a/plugins/talk-plugin-local-auth/client/components/ChangePassword.css b/plugins/talk-plugin-local-auth/client/components/ChangePassword.css new file mode 100644 index 000000000..c1c6c9c7c --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangePassword.css @@ -0,0 +1,95 @@ +.container { + position: relative; + color: #202020; + border-radius: 2px; + border: solid 1px transparent; + box-sizing: border-box; + justify-content: space-between; + margin: 16px 0; + + &.editing { + padding: 10px; + background-color: #EDEDED; + + .actions { + top: 10px; + right: 10px; + } + .title { + margin-bottom: 1em; + } + } +} + +.actions { + position: absolute; + top: -6px; + right: 0px; + display: flex; + flex-direction: column; + align-items: center; +} + +.title { + color: #202020; + margin: 0; +} + +.detailBottomBox { + display: block; + padding-top: 4px; + text-align: right; + width: 230px; +} + +.detailLink { + color: #00538A; + text-decoration: none; + font-size: 0.9em; + &:hover { + cursor: pointer; + } +} + +.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; + } +} diff --git a/plugins/talk-plugin-local-auth/client/components/ChangePassword.js b/plugins/talk-plugin-local-auth/client/components/ChangePassword.js new file mode 100644 index 000000000..8f86fff48 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangePassword.js @@ -0,0 +1,257 @@ +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 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 InputField from './InputField'; +import { getErrorMessages } from 'coral-framework/utils'; + +const initialState = { + editing: false, + showErrors: true, + errors: {}, + formData: {}, +}; + +class ChangePassword extends React.Component { + state = initialState; + validKeys = ['oldPassword', 'newPassword', 'confirmNewPassword']; + + onChange = e => { + const { name, value, type } = e.target; + this.setState( + state => ({ + formData: { + ...state.formData, + [name]: value, + }, + }), + () => { + this.fieldValidation(value, type, name); + + // Perform equality validation if password fields have changed + if (name === 'newPassword' || name === 'confirmNewPassword') { + this.equalityValidation('newPassword', 'confirmNewPassword'); + } + } + ); + }; + + equalityValidation = (field, field2) => { + const cond = this.state.formData[field] === this.state.formData[field2]; + if (!cond) { + this.addError({ + [field2]: t( + 'talk-plugin-local-auth.change_password.passwords_dont_match' + ), + }); + } else { + this.removeError(field2); + } + return cond; + }; + + 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); + } + }; + + hasError = err => { + return Object.keys(this.state.errors).indexOf(err) !== -1; + }; + + addError = err => { + this.setState(({ errors }) => ({ + errors: { ...errors, ...err }, + })); + }; + + removeError = errKey => { + this.setState(state => { + const { [errKey]: _, ...errors } = state.errors; + return { + errors, + }; + }); + }; + + enableEditing = () => { + this.setState({ + editing: true, + }); + }; + + isSubmitBlocked = () => { + const formHasErrors = !!Object.keys(this.state.errors).length; + const formIncomplete = !isEqual( + Object.keys(this.state.formData), + this.validKeys + ); + return formHasErrors || formIncomplete; + }; + + clearForm = () => { + this.setState(initialState); + }; + + onSave = async e => { + e.preventDefault(); + + if (this.isSubmitBlocked()) { + return; + } + + const { oldPassword, newPassword } = this.state.formData; + + try { + await this.props.changePassword({ + oldPassword, + newPassword, + }); + this.props.notify( + 'success', + t('talk-plugin-local-auth.change_password.changed_password_msg') + ); + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + } + + this.clearForm(); + this.disableEditing(); + }; + + 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)); + } + + this.clearForm(); + this.disableEditing(); + }; + + disableEditing = () => { + this.setState({ + editing: false, + }); + }; + + cancel = () => { + this.clearForm(); + this.disableEditing(); + }; + + render() { + const { editing, errors } = this.state; + + return ( +
+

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

+ {editing ? ( +
+ + + + {t('talk-plugin-local-auth.change_password.forgot_password')} + + + + + +
+ + + {t('talk-plugin-local-auth.change_password.cancel')} + +
+ + ) : ( +
+ +
+ )} +
+ ); + } +} + +ChangePassword.propTypes = { + changePassword: PropTypes.func.isRequired, + forgotPassword: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, +}; + +export default ChangePassword; 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..917d77782 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameContentDialog.js @@ -0,0 +1,112 @@ +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/ChangeUsernameDialog.css b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameDialog.css new file mode 100644 index 000000000..af681d596 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameDialog.css @@ -0,0 +1,84 @@ +.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; +} + +.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/ChangeUsernameDialog.js b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameDialog.js new file mode 100644 index 000000000..097168b06 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ChangeUsernameDialog.js @@ -0,0 +1,120 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import styles from './ChangeUsernameDialog.css'; +import InputField from './InputField'; +import { Button, Dialog } from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; + +class ChangeUsernameDialog extends React.Component { + state = { + showError: false, + }; + + showError = () => { + this.setState({ + showError: true, + }); + }; + + confirmChanges = async () => { + 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.saveChanges(); + this.props.closeDialog(); + }; + + 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')} + + +
+
+ + +
+
+
+ ); + } +} + +ChangeUsernameDialog.propTypes = { + saveChanges: PropTypes.func, + closeDialog: PropTypes.func, + showDialog: PropTypes.bool, + onChange: PropTypes.func, + username: PropTypes.string, + formData: PropTypes.object, + canUsernameBeUpdated: PropTypes.bool.isRequired, + notify: PropTypes.func.isRequired, +}; + +export default ChangeUsernameDialog; 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-local-auth/client/components/ErrorMessage.css b/plugins/talk-plugin-local-auth/client/components/ErrorMessage.css new file mode 100644 index 000000000..abcf692fe --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/ErrorMessage.css @@ -0,0 +1,8 @@ +.errorMsg { + color: #FA4643; + font-size: 0.9em; +} + +.warningIcon { + color: #FA4643; +} \ No newline at end of file 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..930c15582 --- /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 ? { value } : {}), + ...(defaultValue ? { 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..08187e238 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/Profile.css @@ -0,0 +1,133 @@ +.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; + 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..85c1f1295 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/components/Profile.js @@ -0,0 +1,283 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './Profile.css'; +import { Button } 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 { emailAddress, username } = this.props; + const formHasErrors = !!Object.keys(this.state.errors).length; + const validUsername = + formData.newUsername && formData.newUsername !== username; + const validEmail = formData.newEmail && formData.newEmail !== emailAddress; + + 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 () => { + const { newEmail, confirmPassword } = 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)); + } + }; + + 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' + )} + + + +
+
+
+ + + {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/containers/AddEmailAddressDialog.js b/plugins/talk-plugin-local-auth/client/containers/AddEmailAddressDialog.js new file mode 100644 index 000000000..841dc8420 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/containers/AddEmailAddressDialog.js @@ -0,0 +1,30 @@ +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'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +const withData = withFragments({ + root: gql` + fragment TalkPluginLocalAuth_AddEmailAddressDialog_root on RootQuery { + me { + id + email + } + settings { + requireEmailConfirmation + } + } + `, +}); + +export default compose( + connect(null, mapDispatchToProps), + withAttachLocalAuth, + withData, + excludeIf(({ root: { me } }) => !me || me.email) +)(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..9c89e0490 --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/index.js @@ -0,0 +1,15 @@ +import ChangePassword from './containers/ChangePassword'; +import AddEmailAddressDialog from './containers/AddEmailAddressDialog'; +import Profile from './containers/Profile'; +import translations from './translations.yml'; +import graphql from './graphql'; + +export default { + translations, + slots: { + profileHeader: [Profile], + profileSettings: [ChangePassword], + stream: [AddEmailAddressDialog], + }, + ...graphql, +}; diff --git a/plugins/talk-plugin-local-auth/client/translations.yml b/plugins/talk-plugin-local-auth/client/translations.yml new file mode 100644 index 000000000..8a3d7c90e --- /dev/null +++ b/plugins/talk-plugin-local-auth/client/translations.yml @@ -0,0 +1,86 @@ +en: + 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 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" + 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:" + 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" +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" + 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-local-auth/index.js b/plugins/talk-plugin-local-auth/index.js new file mode 100644 index 000000000..7e4ee6a3c --- /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, 'server', '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..389574d2d --- /dev/null +++ b/plugins/talk-plugin-local-auth/server/mutators.js @@ -0,0 +1,155 @@ +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. + await User.update( + { + id: user.id, + profiles: { $elemMatch: { provider: 'local' } }, + }, + { + $set: { 'profiles.$.id': email }, + $unset: { 'profiles.$.metadata.confirmed_at': 1 }, + } + ); + + // Get some context for the email to be sent. + const { organizationContactEmail } = await Settings.load([ + '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/translations.yml b/plugins/talk-plugin-local-auth/server/translations.yml new file mode 100644 index 000000000..d81de6c6d --- /dev/null +++ b/plugins/talk-plugin-local-auth/server/translations.yml @@ -0,0 +1,9 @@ +en: + email: + email_change_original: + subject: Email change + body: Your email address has been changed from {0} to {1}. If you did not initiate this change, please contact {2}. # TODO: update translation + 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. 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-notifications/server/views/unsubscribe-notifications.ejs b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs index a34a45dbb..129c3115c 100644 --- a/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs +++ b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs @@ -3,9 +3,9 @@ <%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %> + <%- include(root + '/partials/head') %> - <%- include(root + '/partials/head') %>
diff --git a/plugins/talk-plugin-profile-data/README.md b/plugins/talk-plugin-profile-data/README.md new file mode 100644 index 000000000..c4fcbb456 --- /dev/null +++ b/plugins/talk-plugin-profile-data/README.md @@ -0,0 +1,33 @@ +--- +title: talk-plugin-profile-data +layout: plugin +permalink: /plugin/talk-plugin-profile-data/ +plugin: + name: talk-plugin-profile-data + default: true + provides: + - Client + - Server +--- + +Provides a series of profile data management utilities to users via their +profile tab. + +## Download My Profile + +Enables the ability for users to download their profile data in a zip file from +their profile tab in the comment stream. Once clicked, an email will be sent +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 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/.eslintrc.json b/plugins/talk-plugin-profile-data/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.css b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.css new file mode 100644 index 000000000..0dada08b5 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.css @@ -0,0 +1,44 @@ +.container { + border: 1px solid #f26563; + border-radius: 2px; + color: #3b4a53; + padding: 20px 10px; + background-color: rgba(242, 101, 99, 0.1); + margin: 20px 0; +} + +.button { + color: #787D80; + border-radius: 2px; + background-color: transparent; + height: 30px; + font-size: 0.9em; + line-height: normal; + + &:hover { + background-color: #eaeaea; + } + + &.secondary { + background-color: #787D80; + color: white; + } +} + +.title { + margin: 0; + i.icon { + font-size: 1em; + padding: 4px; + } +} + +.description { + margin: 0; + padding-left: 22px; + padding-bottom: 15px; +} + +.actions { + text-align: center; +} \ No newline at end of file diff --git a/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.js b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.js new file mode 100644 index 000000000..38d968c1d --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/AccountDeletionRequestedSign.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import { t } from 'plugin-api/beta/client/services'; +import moment from 'moment'; +import { Button, Icon } from 'plugin-api/beta/client/components/ui'; +import styles from './AccountDeletionRequestedSign.css'; +import { getErrorMessages } from 'coral-framework/utils'; +import { scheduledDeletionDelayHours } from '../../config'; + +class AccountDeletionRequestedSign extends React.Component { + cancelAccountDeletion = async () => { + const { cancelAccountDeletion, notify } = this.props; + try { + await cancelAccountDeletion(); + notify('success', t('delete_request.account_deletion_cancelled')); + } catch (err) { + notify('error', getErrorMessages(err)); + } + }; + + render() { + const { me: { scheduledDeletionDate } } = this.props.root; + + const deletionScheduledFor = moment(scheduledDeletionDate).format( + 'MMM Do YYYY, h:mm a' + ); + const deletionScheduledOn = moment(scheduledDeletionDate) + .subtract(scheduledDeletionDelayHours, 'hours') + .format('MMM Do YYYY, h:mm a'); + + return ( +
+

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

+

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

+

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

+
+ +
+
+ ); + } +} + +AccountDeletionRequestedSign.propTypes = { + cancelAccountDeletion: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, +}; + +export default AccountDeletionRequestedSign; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.css b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccount.css new file mode 100644 index 000000000..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..bbada134f --- /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_requested')); + } catch (err) { + notify('error', getErrorMessages(err)); + } + }; + + requestAccountDeletion = async () => { + const { requestAccountDeletion, notify } = this.props; + try { + await requestAccountDeletion(); + notify('success', t('delete_request.account_deletion_requested')); + } catch (err) { + notify('error', getErrorMessages(err)); + } + }; + + render() { + const { + me: { scheduledDeletionDate }, + settings: { organizationContactEmail }, + } = this.props.root; + return ( +
+ +

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

+

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

+

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

+ {scheduledDeletionDate ? ( + + ) : ( + + )} +
+ ); + } +} + +DeleteMyAccount.propTypes = { + requestAccountDeletion: PropTypes.func.isRequired, + cancelAccountDeletion: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, +}; + +export default DeleteMyAccount; diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.css b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.css new file mode 100644 index 000000000..2735474da --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.css @@ -0,0 +1,38 @@ +.dialog { + border: none; + box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); + width: 380px; + top: 10px; + font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif; + font-size: 14px; + border-radius: 4px; + padding: 20px; +} + +.close { + font-size: 20px; + line-height: 14px; + top: 10px; + right: 10px; + position: absolute; + display: block; + font-weight: bold; + color: #363636; + cursor: pointer; + + &:hover { + color: #6b6b6b; + } +} + +.title { + font-size: 1.2em; + margin-top: 0; + margin-bottom: 8px; +} + +.description { + font-size: 1em; + line-height: 20px; + margin: 0; +} \ No newline at end of file diff --git a/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.js b/plugins/talk-plugin-profile-data/client/components/DeleteMyAccountDialog.js new file mode 100644 index 000000000..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.css b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css new file mode 100644 index 000000000..55ea39969 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css @@ -0,0 +1,12 @@ +.button { + margin: 0; + + i { + font-size: inherit; + vertical-align: sub; + } +} + +.most_recent { + color: #808080; +} diff --git a/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js new file mode 100644 index 000000000..7af15b774 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react'; +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); + + return durAsHours > 23 + ? durAsDays > 1 + ? t('download_request.days', durAsDays) + : t('download_request.day', durAsDays) + : durAsHours > 1 + ? t('download_request.hours', durAsHours) + : t('download_request.hour', 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 } } } = this.props; + + const now = new Date(); + const lastAccountDownloadDate = + lastAccountDownload && new Date(lastAccountDownload); + const hoursLeft = lastAccountDownloadDate + ? Math.ceil( + downloadRateLimitDays * 24 - + (now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6 + ) + : 0; + const canRequestDownload = !lastAccountDownloadDate || hoursLeft <= 0; + + return ( +
+

{t('download_request.section_title')}

+

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

+ {lastAccountDownloadDate && ( +

+ {t('download_request.most_recent_request')}:{' '} + {lastAccountDownloadDate.toLocaleString()} +

+ )} + {canRequestDownload ? ( + + ) : ( + + )} +
+ ); + } +} + +export default DownloadCommentHistory; diff --git a/plugins/talk-plugin-profile-data/client/components/ErrorMessage.css b/plugins/talk-plugin-profile-data/client/components/ErrorMessage.css new file mode 100644 index 000000000..19f6940b1 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/ErrorMessage.css @@ -0,0 +1,12 @@ +.errorMsg { + color: #FA4643; + font-size: 0.9em; + + i.warningIcon { + font-size: 17px; + } +} + +.warningIcon { + color: #FA4643; +} diff --git a/plugins/talk-plugin-profile-data/client/components/ErrorMessage.js b/plugins/talk-plugin-profile-data/client/components/ErrorMessage.js new file mode 100644 index 000000000..f39a8fc08 --- /dev/null +++ b/plugins/talk-plugin-profile-data/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-profile-data/client/components/InputField.css b/plugins/talk-plugin-profile-data/client/components/InputField.css new file mode 100644 index 000000000..fae31c80a --- /dev/null +++ b/plugins/talk-plugin-profile-data/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-profile-data/client/components/InputField.js b/plugins/talk-plugin-profile-data/client/components/InputField.js new file mode 100644 index 000000000..930c15582 --- /dev/null +++ b/plugins/talk-plugin-profile-data/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 ? { value } : {}), + ...(defaultValue ? { 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-profile-data/client/components/StepProgress.css b/plugins/talk-plugin-profile-data/client/components/StepProgress.css new file mode 100644 index 000000000..bede31511 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/StepProgress.css @@ -0,0 +1,36 @@ +.step { + color: #BBBEBF; + background-color: white; + padding: 6px; + z-index: 10; + + > .icon { + font-size: 25px; + } + + &.current { + color: rgba(0, 205, 115, 0.3); + } + + &.completed { + color: #00CD73; + } +} + +.line { + position: absolute; + border: solid 2px #BBBEBF; + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 0; +} + +.container { + display: flex; + position: relative; + justify-content: space-between; + align-items: center; + height: 50px; + margin: 0 20px; +} \ No newline at end of file diff --git a/plugins/talk-plugin-profile-data/client/components/StepProgress.js b/plugins/talk-plugin-profile-data/client/components/StepProgress.js new file mode 100644 index 000000000..ff32fe431 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/StepProgress.js @@ -0,0 +1,48 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './StepProgress.css'; +import { Icon } from 'plugin-api/beta/client/components/ui'; + +const CheckItem = ({ current = false, completed = false }) => ( + + + +); + +CheckItem.propTypes = { + current: PropTypes.bool.isRequired, + completed: PropTypes.bool.isRequired, +}; + +const Line = () =>
; + +class StepProgress extends React.Component { + render() { + const { currentStep, totalSteps } = this.props; + return ( +
+ {Array.from({ length: totalSteps }).map((_, i) => ( + + ))} + +
+ ); + } +} + +StepProgress.propTypes = { + currentStep: PropTypes.number.isRequired, + totalSteps: PropTypes.number.isRequired, +}; + +export default StepProgress; diff --git a/plugins/talk-plugin-profile-data/client/containers/AccountDeletionRequestedSign.js b/plugins/talk-plugin-profile-data/client/containers/AccountDeletionRequestedSign.js new file mode 100644 index 000000000..ff54198dd --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/containers/AccountDeletionRequestedSign.js @@ -0,0 +1,25 @@ +import { compose, gql } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { connect, withFragments, excludeIf } from 'plugin-api/beta/client/hocs'; +import AccountDeletionRequestedSign from '../components/AccountDeletionRequestedSign'; +import { notify } from 'coral-framework/actions/notification'; +import { withCancelAccountDeletion } from '../hocs'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +const withData = withFragments({ + root: gql` + fragment Talk_AccountDeletionRequestedSignIn_root on RootQuery { + me { + scheduledDeletionDate + } + } + `, +}); + +export default compose( + connect(null, mapDispatchToProps), + withCancelAccountDeletion, + withData, + excludeIf(({ root: { me } }) => !me || !me.scheduledDeletionDate) +)(AccountDeletionRequestedSign); diff --git a/plugins/talk-plugin-profile-data/client/containers/DeleteMyAccount.js b/plugins/talk-plugin-profile-data/client/containers/DeleteMyAccount.js new file mode 100644 index 000000000..ac244d8b8 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/containers/DeleteMyAccount.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 DeleteMyAccount from '../components/DeleteMyAccount'; +import { notify } from 'coral-framework/actions/notification'; +import { withRequestAccountDeletion, withCancelAccountDeletion } from '../hocs'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +const withData = withFragments({ + root: gql` + fragment Talk_DeleteMyAccount_root on RootQuery { + me { + scheduledDeletionDate + } + settings { + organizationContactEmail + } + } + `, +}); + +export default compose( + connect(null, mapDispatchToProps), + withRequestAccountDeletion, + withCancelAccountDeletion, + withData +)(DeleteMyAccount); diff --git a/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js b/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js new file mode 100644 index 000000000..1716de983 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { compose, gql } from 'react-apollo'; +import DownloadCommentHistory from '../components/DownloadCommentHistory'; +import { withRequestDownloadLink } from '../hocs'; +import { connect, withFragments } from 'plugin-api/beta/client/hocs'; +import { notify } from 'coral-framework/actions/notification'; + +class DownloadCommentHistoryContainer extends Component { + static propTypes = { + requestDownloadLink: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, + }; + + render() { + return ( + + ); + } +} + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +const enhance = compose( + connect(null, mapDispatchToProps), + withFragments({ + root: gql` + fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery { + __typename + me { + id + lastAccountDownload + } + } + `, + }), + withRequestDownloadLink +); + +export default enhance(DownloadCommentHistoryContainer); diff --git a/plugins/talk-plugin-profile-data/client/graphql.js b/plugins/talk-plugin-profile-data/client/graphql.js new file mode 100644 index 000000000..9b3711bfc --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/graphql.js @@ -0,0 +1,26 @@ +import update from 'immutability-helper'; +import { createDefaultResponseFragments } from 'coral-framework/utils'; + +export default { + fragments: { + ...createDefaultResponseFragments( + 'RequestAccountDeletionResponse', + 'RequestDownloadLinkResponse', + 'CancelAccountDeletionResponse' + ), + }, + mutations: { + DownloadCommentHistory: () => ({ + updateQueries: { + CoralEmbedStream_Profile: previousData => + update(previousData, { + me: { + lastAccountDownload: { + $set: new Date().toISOString(), + }, + }, + }), + }, + }), + }, +}; diff --git a/plugins/talk-plugin-profile-data/client/hocs/index.js b/plugins/talk-plugin-profile-data/client/hocs/index.js new file mode 100644 index 000000000..c90ad21df --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/hocs/index.js @@ -0,0 +1,111 @@ +import { withMutation } from 'plugin-api/beta/client/hocs'; +import { gql } from 'react-apollo'; +import moment from 'moment'; +import update from 'immutability-helper'; + +export const withRequestDownloadLink = withMutation( + gql` + mutation DownloadCommentHistory { + requestDownloadLink { + errors { + translation_key + } + } + } + `, + { + props: ({ mutate }) => ({ + requestDownloadLink: () => mutate({ variables: {} }), + }), + } +); + +export const withRequestAccountDeletion = withMutation( + gql` + mutation RequestAccountDeletion { + requestAccountDeletion { + ...RequestAccountDeletionResponse + } + } + `, + { + props: ({ mutate }) => ({ + requestAccountDeletion: () => { + return mutate({ + variables: {}, + update: proxy => { + const RequestAccountDeletionQuery = gql` + query Talk_CancelAccountDeletion { + me { + id + scheduledDeletionDate + } + } + `; + + const prev = proxy.readQuery({ + query: RequestAccountDeletionQuery, + }); + + const scheduledDeletionDate = moment() + .add(24, 'hours') + .toDate(); + + const data = update(prev, { + me: { + scheduledDeletionDate: { $set: scheduledDeletionDate }, + }, + }); + + proxy.writeQuery({ + query: RequestAccountDeletionQuery, + data, + }); + }, + }); + }, + }), + } +); + +export const withCancelAccountDeletion = withMutation( + gql` + mutation RequestDownloadLink { + cancelAccountDeletion { + ...CancelAccountDeletionResponse + } + } + `, + { + props: ({ mutate }) => ({ + cancelAccountDeletion: () => { + return mutate({ + variables: {}, + update: proxy => { + const CancelAccountDeletionQuery = gql` + query Talk_CancelAccountDeletion { + me { + id + scheduledDeletionDate + } + } + `; + + const prev = proxy.readQuery({ query: CancelAccountDeletionQuery }); + + const data = update(prev, { + me: { + scheduledDeletionDate: { $set: null }, + }, + }); + + proxy.writeQuery({ + query: CancelAccountDeletionQuery, + data, + }); + }, + }); + }, + }), + } +); diff --git a/plugins/talk-plugin-profile-data/client/index.js b/plugins/talk-plugin-profile-data/client/index.js new file mode 100644 index 000000000..bbd30b660 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/index.js @@ -0,0 +1,14 @@ +import DownloadCommentHistory from './containers/DownloadCommentHistory'; +import DeleteMyAccount from './containers/DeleteMyAccount'; +import AccountDeletionRequestedSign from './containers/AccountDeletionRequestedSign'; +import translations from './translations.yml'; +import graphql from './graphql'; + +export default { + slots: { + stream: [AccountDeletionRequestedSign], + profileSettings: [DownloadCommentHistory, DeleteMyAccount], + }, + translations, + ...graphql, +}; diff --git a/plugins/talk-plugin-profile-data/client/translations.yml b/plugins/talk-plugin-profile-data/client/translations.yml new file mode 100644 index 000000000..f38958e7f --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/translations.yml @@ -0,0 +1,51 @@ +en: + download_request: + section_title: "Download My Comment History" + you_will_get_a_copy: "You will recieve an email with a link to download your comment history. You can make" + download_rate: "one download request every {0} days" + most_recent_request: "Your most recent request" + request: "Request Comment History" + rate_limit: "You can submit another Comment History request in {0}" + hours: "{0} hours" + days: "{0} days" + hour: "{0} hour" + day: "{0} day" + download_preparing: "Account Download Preparing - Check your email shortly for a download link" + delete_request: + account_deletion_cancelled: 'Account Deletion Request Cancelled - Your request to delete your account has been cancelled.' + account_deletion_requested: 'Account Deletion Requested' + received_on: "A request to delete your account was received on " + cancel_request_description: "If you would like to reactivate your account, you may cancel your request to delete your account below" + before: "before" + cancel_account_deletion_request: "Cancel Account Deletion Request" + delete_my_account: "Delete My Account" + delete_my_account_description: "Deleting your account will permanently erase your profile and remove all your comments from this site." + already_submitted_request_description: "You have already submitted a request to delete your account. Your account will be deleted on {0}. You may cancel the request until that time" + your_request_submitted_description: "Your request has been submitted and confirmation has been sent to the email address associated with your account." + your_account_deletion_scheduled: "Your account is scheduled to be deleted at:" + changed_your_mind: "Changed your mind?" + simply_go_to: "Simply go to your account again before this time and click" + tell_us_why: "Tell us why" + feedback_copy: "We would like to know why you chose to delete your account. Send us feedback on our comment system by emailing" + done: "Done" + cancel: "Cancel" + proceed: "Proceed" + input_is_not_correct: "The input is not correct" + step_0: + you_are_attempting: "You are attempting to delete your account. This means:" + item_1: "All of your comments are removed from this site" + item_2: "All of your comments are deleted from our database" + item_3: "Your username and email address are removed from our system" + step_1: + subtitle: "When will my account be deleted?" + description: "Your account will be deleted {0} hours after your request has been submitted." + subtitle_2: "Can I still write comments until my account is deleted?" + description_2: "Yes, you can still comment, reply, and react to comments until the {0} hours expires." + step_2: + description: "Before your account is deleted, we recommend you download your comment history for your records. After your account is deleted, you will be unable to request your comment history." + to_download: "To download your comment history go to:" + path: "My Profile > Download My Comment History" + step_3: + subtitle: "Are you sure you want to delete your account?" + description: "To confirm you would like to delete your account please type in the following phrase into the text box below:" + type_to_confirm: "Type phrase below to confirm" diff --git a/plugins/talk-plugin-profile-data/config.js b/plugins/talk-plugin-profile-data/config.js new file mode 100644 index 000000000..f0406be75 --- /dev/null +++ b/plugins/talk-plugin-profile-data/config.js @@ -0,0 +1,4 @@ +module.exports = { + scheduledDeletionDelayHours: 24, + downloadRateLimitDays: 7, +}; diff --git a/plugins/talk-plugin-profile-data/index.js b/plugins/talk-plugin-profile-data/index.js new file mode 100644 index 000000000..7bfa81748 --- /dev/null +++ b/plugins/talk-plugin-profile-data/index.js @@ -0,0 +1,15 @@ +const path = require('path'); +const router = require('./server/router'); +const mutators = require('./server/mutators'); +const typeDefs = require('./server/typeDefs'); +const connect = require('./server/connect'); +const resolvers = require('./server/resolvers'); + +module.exports = { + mutators, + router, + connect, + typeDefs, + translations: path.join(__dirname, 'translations.yml'), + resolvers, +}; diff --git a/plugins/talk-plugin-profile-data/package.json b/plugins/talk-plugin-profile-data/package.json new file mode 100644 index 000000000..4329aba7e --- /dev/null +++ b/plugins/talk-plugin-profile-data/package.json @@ -0,0 +1,13 @@ +{ + "name": "@coralproject/talk-plugin-profile-data", + "version": "1.0.0", + "description": "Adds profile data management for Talk", + "main": "index.js", + "license": "Apache-2.0", + "private": false, + "dependencies": { + "archiver": "^2.1.1", + "cron": "^1.3.0", + "csv-stringify": "^3.0.0" + } +} diff --git a/plugins/talk-plugin-profile-data/server/connect.js b/plugins/talk-plugin-profile-data/server/connect.js new file mode 100644 index 000000000..0e5284e57 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/connect.js @@ -0,0 +1,146 @@ +const path = require('path'); +const moment = require('moment'); +const { CronJob } = require('cron'); +const { get } = require('lodash'); +const { ErrMissingEmail } = require('errors'); + +module.exports = connectors => { + const { + services: { Mailer, I18n }, + models: { User }, + graph: { Context }, + } = connectors; + + // Setup the mail templates. + ['txt', 'html'].forEach(format => { + Mailer.templates.register( + path.join(__dirname, 'emails', `download.${format}.ejs`), + 'download', + format + ); + }); + + // Setup the cron job that will scan for accounts to delete every 30 minutes. + new CronJob({ + cronTime: '0,30 * * * *', + timeZone: 'America/New_York', + start: true, + runOnInit: true, + onTick: async () => { + // Create the context we'll use to perform user deletions. + const ctx = Context.forSystem(); + + try { + // Grab some settings. + const { loaders: { Settings } } = ctx; + const { + organizationName, + organizationContactEmail, + } = await Settings.load([ + 'organizationName', + 'organizationContactEmail', + ]); + + // rescheduledDeletionDate is the date in the future that we'll set the + // user's account to be deleted on if this delete fails. + const rescheduledDeletionDate = moment() + .add(1, 'hours') + .toDate(); + + // Keep running for each user we can pull. + while (true) { + // We'll find any user that has an account deletion date before now + // and update the user such that their deletion time is 1 hour from + // now. This will ensure that only one instance can pull the same + // user at a time, and if the delete fails, it will be retried an + // hour from now. If the deletion was successful, well, it can't be + // retried because the reference to the scheduledDeletionDate will + // get deleted along with the user. + const user = await User.findOneAndUpdate( + { + 'metadata.scheduledDeletionDate': { $lte: new Date() }, + }, + { + $set: { + 'metadata.scheduledDeletionDate': rescheduledDeletionDate, + }, + } + ); + if (!user) { + // There are no more users that meet the search criteria! We're + // done! + ctx.log.info('no more users are scheduled for deletion'); + break; + } + + // Get the user's email address. + const reply = await ctx.graphql( + ` + query GetUserEmailAddress($user_id: ID!) { + user(id: $user_id) { + email + } + } + `, + { user_id: user.id } + ); + if (reply.errors) { + throw reply.errors; + } + + const email = get(reply, 'data.user.email'); + if (!email) { + throw new ErrMissingEmail(); + } + + ctx.log.info( + { + userID: user.id, + scheduledDeletionDate: user.metadata.scheduledDeletionDate, + }, + 'starting user delete' + ); + + // Delete the user using the existing graph call. + const { data, errors } = await ctx.graphql( + ` + mutation DeleteUser($user_id: ID!) { + delUser(id: $user_id) { + errors { + translation_key + } + } + } + `, + { user_id: user.id } + ); + if (errors) { + throw errors; + } + + if (data.errors) { + throw data.errors; + } + + ctx.log.info({ userID: user.id }, 'user was deleted successfully'); + + // Send the download link via the user's attached email account. + await Mailer.send({ + template: 'plain', + locals: { + body: I18n.t( + 'email.deleted.body', + organizationName, + organizationContactEmail + ), + }, + subject: I18n.t('email.deleted.subject', organizationName), + email, + }); + } + } catch (err) { + ctx.log.error({ err }, 'could not handle user deletions'); + } + }, + }); +}; diff --git a/plugins/talk-plugin-profile-data/server/constants.js b/plugins/talk-plugin-profile-data/server/constants.js new file mode 100644 index 000000000..6183e8bbe --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/constants.js @@ -0,0 +1,3 @@ +module.exports = { + DOWNLOAD_LINK_SUBJECT: 'download_link', +}; diff --git a/plugins/talk-plugin-profile-data/server/emails/download.html.ejs b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs new file mode 100644 index 000000000..974ea71f4 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs @@ -0,0 +1 @@ +

<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= t('email.download.download_archive') %>

diff --git a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs new file mode 100644 index 000000000..940d62bad --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs @@ -0,0 +1,3 @@ +<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> + + <%= downloadLandingURL %> diff --git a/plugins/talk-plugin-profile-data/server/errors.js b/plugins/talk-plugin-profile-data/server/errors.js new file mode 100644 index 000000000..f205c1f6e --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/errors.js @@ -0,0 +1,43 @@ +const { TalkError } = require('errors'); + +// ErrDownloadToken is returned in the event that the download is requested +// without a valid token. +class ErrDownloadToken extends TalkError { + constructor(err) { + super( + 'Token is invalid', + { + translation_key: 'DOWNLOAD_TOKEN_INVALID', + status: 400, + }, + { err } + ); + } +} + +// ErrDeletionAlreadyScheduled is returned when a user requests that their +// account get deleted when their account is already scheduled for deletion. +class ErrDeletionAlreadyScheduled extends TalkError { + constructor() { + super('Deletion is already scheduled', { + translation_key: 'DELETION_ALREADY_SCHEDULED', + status: 400, + }); + } +} +// ErrDeletionNotScheduled is returned when a user requests that their +// account deletion to be canceled when it was not scheduled for deletion. +class ErrDeletionNotScheduled extends TalkError { + constructor() { + super('Deletion was not scheduled', { + translation_key: 'DELETION_NOT_SCHEDULED', + status: 400, + }); + } +} + +module.exports = { + ErrDownloadToken, + ErrDeletionAlreadyScheduled, + ErrDeletionNotScheduled, +}; diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js new file mode 100644 index 000000000..eec54772c --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -0,0 +1,218 @@ +const { get } = require('lodash'); +const moment = require('moment'); +const uuid = require('uuid/v4'); +const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { + ErrDeletionAlreadyScheduled, + ErrDeletionNotScheduled, +} = require('./errors'); +const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors'); +const { URL } = require('url'); +const { + scheduledDeletionDelayHours, + downloadRateLimitDays, +} = require('../config'); + +// generateDownloadLinks will generate a signed set of links for a given user to +// download an archive of their data. +async function generateDownloadLinks(ctx, userID) { + const { connectors: { url: { BASE_URL }, secrets } } = ctx; + + // Generate a token for the download link. + const token = await secrets.jwt.sign( + { user: userID }, + { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT } + ); + + // Generate the url that a user can land on. + const downloadLandingURL = new URL('account/download', BASE_URL); + downloadLandingURL.hash = token; + + // Generate the url that the API calls to download the actual zip. + const downloadFileURL = new URL('api/v1/account/download', BASE_URL); + downloadFileURL.searchParams.set('token', token); + + return { + downloadLandingURL: downloadLandingURL.href, + downloadFileURL: downloadFileURL.href, + }; +} + +async function sendDownloadLink(ctx) { + const { + user, + loaders: { Settings }, + connectors: { services: { Users, I18n, Limit }, models: { User } }, + } = ctx; + + // downloadLinkLimiter can be used to limit downloads for the user's data to + // once every ${downloadRateLimitDays} days. + const downloadLinkLimiter = new Limit( + 'profileDataDownloadLimiter', + 1, + `${downloadRateLimitDays}d` + ); + + // Check that the user has not already requested a download within the last + // ${downloadRateLimitDays} days. + const attempts = await downloadLinkLimiter.get(user.id); + if (attempts && attempts >= 1) { + throw new ErrMaxRateLimit(); + } + + // Check if the lastAccountDownload time is within ${downloadRateLimitDays} + // days. + if ( + user.lastAccountDownload && + moment(user.lastAccountDownload) + .add(downloadRateLimitDays, 'days') + .isAfter(moment()) + ) { + throw new ErrMaxRateLimit(); + } + + // The account currently does not have a download link, let's record the + // download. This will throw an error if a race ocurred and we should stop + // now. + await downloadLinkLimiter.test(user.id); + + const now = new Date(); + + // Generate the download links. + const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id); + + const { organizationName } = await Settings.load('organizationName'); + + // Send the download link via the user's attached email account. + await Users.sendEmail(user, { + template: 'download', + locals: { + downloadLandingURL, + organizationName, + now, + }, + subject: I18n.t('email.download.subject', organizationName), + }); + + // Amend the lastAccountDownload on the user. + await User.update( + { id: user.id }, + { $set: { 'metadata.lastAccountDownload': now } } + ); +} + +// requestDeletion will schedule the current user to have their account deleted +// by setting the `scheduledDeletionDate` on the user +// ${scheduledDeletionDelayHours} hours from now. +async function requestDeletion({ + user, + loaders: { Settings }, + connectors: { models: { User }, services: { Users, I18n } }, +}) { + // Ensure the user doesn't already have a deletion scheduled. + if (get(user, 'metadata.scheduledDeletionDate')) { + throw new ErrDeletionAlreadyScheduled(); + } + + // Get the date in the future ${scheduledDeletionDelayHours} hours from now. + const scheduledDeletionDate = moment().add( + scheduledDeletionDelayHours, + 'hours' + ); + + // Amend the scheduledDeletionDate on the user. + await User.update( + { id: user.id }, + { + $set: { + 'metadata.scheduledDeletionDate': scheduledDeletionDate.toDate(), + }, + } + ); + + const { organizationName } = await Settings.load('organizationName'); + + // Send the download link via the user's attached email account. + await Users.sendEmail(user, { + template: 'plain', + locals: { + body: I18n.t( + 'email.delete.body', + organizationName, + scheduledDeletionDate.format('MMM Do YYYY, h:mm:ss a') + ), + }, + subject: I18n.t('email.delete.subject', organizationName), + }); + + return scheduledDeletionDate.toDate(); +} + +// cancelDeletion will unset the scheduled deletion date on the user account +// that is used to indicate that the user was scheduled for deletion. +async function cancelDeletion({ + user, + loaders: { Settings }, + connectors: { models: { User }, services: { I18n, Users } }, +}) { + // Ensure the user has a deletion scheduled. + const scheduledDeletionDate = get( + user, + 'metadata.scheduledDeletionDate', + null + ); + if (!scheduledDeletionDate) { + throw new ErrDeletionNotScheduled(); + } + + // Amend the scheduledDeletionDate on the user. + await User.update( + { id: user.id }, + { $unset: { 'metadata.scheduledDeletionDate': 1 } } + ); + + const { organizationName } = await Settings.load('organizationName'); + + // Send the download link via the user's attached email account. + await Users.sendEmail(user, { + template: 'plain', + locals: { + body: I18n.t( + 'email.cancelDelete.body', + organizationName, + moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a') + ), + }, + subject: I18n.t('email.cancelDelete.subject', organizationName), + }); +} + +// downloadUser will return the download file url that can be used to directly +// download the archive. +async function downloadUser(ctx, userID) { + if (ctx.user.role !== 'ADMIN') { + throw new ErrNotAuthorized(); + } + + const { downloadFileURL } = await generateDownloadLinks(ctx, userID); + return downloadFileURL; +} + +module.exports = ctx => + ctx.user + ? { + User: { + requestDownloadLink: () => sendDownloadLink(ctx), + requestDeletion: () => requestDeletion(ctx), + cancelDeletion: () => cancelDeletion(ctx), + download: userID => downloadUser(ctx, userID), + }, + } + : { + User: { + requestDownloadLink: () => Promise.reject(new ErrNotAuthorized()), + requestDeletion: () => Promise.reject(new ErrNotAuthorized()), + cancelDeletion: () => Promise.reject(new ErrNotAuthorized()), + download: () => Promise.reject(new ErrNotAuthorized()), + }, + }; diff --git a/plugins/talk-plugin-profile-data/server/resolvers.js b/plugins/talk-plugin-profile-data/server/resolvers.js new file mode 100644 index 000000000..9a5a90638 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -0,0 +1,41 @@ +const { get } = require('lodash'); + +module.exports = { + RootMutation: { + requestDownloadLink: async (_, args, { mutators: { User } }) => { + await User.requestDownloadLink(); + }, + requestAccountDeletion: async (_, args, { mutators: { User } }) => ({ + scheduledDeletionDate: await User.requestDeletion(), + }), + cancelAccountDeletion: async (_, args, { mutators: { User } }) => { + await User.cancelDeletion(); + }, + downloadUser: async (_, { id }, { mutators: { User } }) => ({ + archiveURL: await User.download(id), + }), + }, + User: { + lastAccountDownload: (user, args, { user: currentUser }) => { + // If the current user is not the requesting user, and the user is not + // an admin, return nothing. + if (user.id !== currentUser.id && user.role !== 'ADMIN') { + return null; + } + + return get(user, 'metadata.lastAccountDownload', null); + }, + scheduledDeletionDate: (user, args, { user: currentUser }) => { + // If the current user is not the requesting user, and the user is not + // an admin or a moderator, return nothing. + if ( + user.id !== currentUser.id && + !['ADMIN', 'MODERATOR'].includes(user.role) + ) { + return null; + } + + return get(user, 'metadata.scheduledDeletionDate', null); + }, + }, +}; diff --git a/plugins/talk-plugin-profile-data/server/router.js b/plugins/talk-plugin-profile-data/server/router.js new file mode 100644 index 000000000..2a2e0161a --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/router.js @@ -0,0 +1,200 @@ +const path = require('path'); +const express = require('express'); +const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { get, pick, kebabCase } = require('lodash'); +const moment = require('moment'); +const archiver = require('archiver'); +const stringify = require('csv-stringify'); +const { ErrDownloadToken } = require('./errors'); + +async function verifyDownloadToken( + { connectors: { services: { Users } } }, + token +) { + const jwt = await Users.verifyToken(token, { + subject: DOWNLOAD_LINK_SUBJECT, + }); + + return jwt; +} + +// loadCommentsBatch will load a batch of the comments and write them to the +// stream. +async function loadCommentsBatch(ctx, csv, variables) { + let result = await ctx.graphql( + ` + query GetMyComments($userID: ID!, $cursor: Cursor) { + user(id: $userID) { + comments(query: { + limit: 100, + cursor: $cursor, + statuses: null + }) { + hasNextPage + endCursor + nodes { + id + created_at + asset { + url + } + body + url + } + } + } + } + `, + variables + ); + if (result.errors) { + throw result.errors; + } + + for (const comment of get(result, 'data.user.comments.nodes', [])) { + csv.write([ + comment.id, + moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'), + get(comment, 'asset.url'), + comment.url, + comment.body, + ]); + } + + return pick(get(result, 'data.user.comments'), ['hasNextPage', 'endCursor']); +} + +// loadComments will load batches of the comments and write them to the csv +// stream. Once the comments have finished writing, it will close the stream. +async function loadComments(ctx, userID, archive, latestContentDate) { + // Create all the csv writers that'll write the data to the archive. + const csv = stringify(); + + // Add all the streams as files to the archive. + archive.append(csv, { name: 'talk-export/my_comments.csv' }); + + csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']); + + // Load the first batch's comments from the latest date that we were provided + // from the token. + let connection = await loadCommentsBatch(ctx, csv, { + cursor: latestContentDate, + userID, + }); + + // As long as there's more comments, keep paginating. + while (connection.hasNextPage) { + connection = await loadCommentsBatch(ctx, csv, { + cursor: connection.endCursor, + userID, + }); + } + + csv.end(); +} + +module.exports = router => { + // /account/download will render the download page. + router.get('/account/download', (req, res) => { + res.render(path.join(__dirname, 'views', 'download')); + }); + + // /api/v1/account/download will send back a zipped archive of the users + // account. + router.all( + '/api/v1/account/download', + express.urlencoded({ extended: false }), + async (req, res, next) => { + let { token = null, check = false } = req.body; + + if (!token) { + // If the token wasn't found in the body, then we should check the query + // to see if it was passed that way. + token = req.query.token; + } + + if (!token) { + return res.status(400).end(); + } + + if (check) { + // This request is checking to see if the token is valid. + try { + // Verify the token + await verifyDownloadToken(req.context, token); + } catch (err) { + return next(new ErrDownloadToken(err)); + } + + res.status(204).end(); + + // Don't continue to pass it onto the next middleware, as we've only been + // asked to verify the token. + return; + } + + const { connectors: { graph: { Context }, errors } } = req.context; + + try { + // Pull the userID and the date that the token was issued out of the + // provided token. + const { user: userID, iat } = await verifyDownloadToken( + req.context, + token + ); + + // Create a system context used to get all comments for that user. + const ctx = Context.forSystem(); + + // Get the current user's username. We need it for the generated filenames. + const result = await ctx.graphql( + `query GetUser($userID: ID!) { + user(id: $userID) { username } + }`, + { userID } + ); + if (result.errors) { + throw result.errors; + } + + const user = get(result, 'data.user'); + if (!user) { + throw new errors.ErrNotFound(); + } + + // Unpack the date that the token was issued, and use it as a source for the + // earliest comment we should include in the download. + const latestContentDate = new Date(iat * 1000); + + // Generate the filename of the file that the user will download. + const username = get(user, 'username'); + const filename = `talk-${kebabCase(username)}-${kebabCase( + moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss') + )}.zip`; + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename=${filename}`, + }); + + // Create the zip archive we'll use to write all the exported files to. + const archive = archiver('zip', { + zlib: { level: 9 }, + }); + + // Pipe this to the response writer directly. + archive.pipe(res); + + // Load the comments csv up with the user's comments. + await loadComments(ctx, userID, archive, latestContentDate); + + // Mark the end of adding files, no more files can be added after this. Once + // all the stream readers have finished writing, and have closed, the + // archiver will close which will finish the HTTP request. + archive.finalize(); + } catch (err) { + return next(err); + } + } + ); +}; diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.graphql b/plugins/talk-plugin-profile-data/server/typeDefs.graphql new file mode 100644 index 000000000..62e27cae4 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -0,0 +1,73 @@ +type User { + + # lastAccountDownload is the date that the user last requested a comment + # download. + lastAccountDownload: Date + + # scheduledDeletionDate is the data for which the user account will be deleted + # after. The account may be deleted up to half an hour after this date because + # the job responsible for deleting the scheduled account will only run once + # every half hour. + scheduledDeletionDate: Date +} + +# RequestDownloadLinkResponse contains the account download errors relating to +# the request for an account download. +type RequestDownloadLinkResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# RequestAccountDeletionResponse contains the account deletion schedule errors +# relating to schedulding an account for deletion. +type RequestAccountDeletionResponse implements Response { + + # scheduledDeletionDate is the data for which the user account will be deleted + # after. The account may be deleted up to half an hour after this date because + # the job responsible for deleting the scheduled account will only run once + # every half hour. + scheduledDeletionDate: Date + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# CancelAccountDeletionResponse contains the account deletion errors relating to +# canceling an account deletion that was scheduled. +type CancelAccountDeletionResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# DownloadUserResponse contaisn the account download archiveURL that can be used +# to directly download a zip file containing the user data. +type DownloadUserResponse implements Response { + + # archiveURL is the link that can be used within the next 1 hour to download a + # users archive. + archiveURL: String + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +type RootMutation { + + # requestDownloadLink will request a download link be sent to the primary + # users email address. + requestDownloadLink: RequestDownloadLinkResponse + + # requestAccountDeletion requests that the current account get deleted. The + # mutation will return the date that the account is scheduled to be deleted. + requestAccountDeletion: RequestAccountDeletionResponse + + # cancelAccountDeletion will cancel a pending account deletion that was + # previously scheduled. + cancelAccountDeletion: CancelAccountDeletionResponse + + # downloadUser will provide an account download for the indicated User. This + # mutation requires the ADMIN role. + downloadUser(id: ID!): DownloadUserResponse +} diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.js b/plugins/talk-plugin-profile-data/server/typeDefs.js new file mode 100644 index 000000000..ccadb70b0 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/typeDefs.js @@ -0,0 +1,7 @@ +const path = require('path'); +const fs = require('fs'); + +module.exports = fs.readFileSync( + path.join(__dirname, 'typeDefs.graphql'), + 'utf8' +); diff --git a/plugins/talk-plugin-profile-data/server/views/download.ejs b/plugins/talk-plugin-profile-data/server/views/download.ejs new file mode 100644 index 000000000..260badf3a --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/views/download.ejs @@ -0,0 +1,56 @@ + + + + <%= t('download_landing.download_your_account') %> + <%- include(root + '/partials/account') %> + + +
+
+

<%= t('download_landing.download_your_account') %>

+

<%= t('download_landing.download_details') %>

+

<%= t('download_landing.all_information_included') %>

+
    +
  • <%= t('download_landing.information_included.date') %>
  • +
  • <%= t('download_landing.information_included.url') %>
  • +
  • <%= t('download_landing.information_included.body') %>
  • +
  • <%= t('download_landing.information_included.asset_url') %>
  • +
+
+
+ +
+
+
+ + + + diff --git a/plugins/talk-plugin-profile-data/translations.yml b/plugins/talk-plugin-profile-data/translations.yml new file mode 100644 index 000000000..39b810cca --- /dev/null +++ b/plugins/talk-plugin-profile-data/translations.yml @@ -0,0 +1,37 @@ +en: + download_landing: + download_your_account: "Download Your Comment History" + download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application." + all_information_included: "For each of your comments the following information is included:" + information_included: + date: "When you wrote the comment" + url: "The permalink URL for the comment" + body: "The comment text" + asset_url: "The URL on the article or story where the comment appears" + confirm: "Download My Comment History" + email: + download: + subject: "Your comments are ready for download from {0}" + download_link_ready: "Click here to download your comments from {0} as of {1}:" + download_archive: "Download Archive" + delete: + subject: "Your account for {0} is scheduled to be deleted" + body: | + A request to delete your account was received. Your account is scheduled for deletion on {1}. + + After that time all of your comments will be removed from the site, all of your comments will be removed from our database, and your username and email address will be removed from our system. + + If you change your mind you can sign into your account and cancel the request before your scheduled account deletion time. + deleted: + subject: "Your account for {0} has been deleted" + body: | + Your commenter account for {0} is now deleted. We're sorry to see you go! + + If you'd like to re-join the discussion in the future, you can sign up for a new account. + + If you'd like to give us feedback on why you left and what we can do to make the commenting experience better, please email us at {1}. + cancelDelete: + subject: "Your account deletion request for {0} has been cancelled" + body: "You have cancelled your account deletion request for {0}. Your account is now reactivated." + error: + DOWNLOAD_TOKEN_INVALID: "Your download link is not valid." diff --git a/plugins/talk-plugin-rich-text/README.md b/plugins/talk-plugin-rich-text/README.md index a69e5c8dc..cac44438e 100644 --- a/plugins/talk-plugin-rich-text/README.md +++ b/plugins/talk-plugin-rich-text/README.md @@ -16,6 +16,9 @@ Enables secure rich text support server-side. Add `"talk-plugin-rich-text"` to the `plugins.json` in your Talk installation. This plugin provides a server and a client side implementation. +###### Note: Possible plugin conflict +The plugin `talk-plugin-comment-content` will prevent this plugin from rendering comments with rich text styling and is not needed if this plugin is enabled. + ## Server implementation ### How does this work? @@ -43,11 +46,11 @@ Settings for highlighting links. These will only apply if `higlightLinks` is set #### `dompurify` -Rules to sanitize html input. We use [DOMPurify] (https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings] (https://github.com/cure53/DOMPurify) +Rules to sanitize html input. We use [DOMPurify](https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings](https://github.com/cure53/DOMPurify) #### `jsdom` -In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings. +In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings. ## Client implementation @@ -58,10 +61,10 @@ This plugin contains 2 important components: - The Editor (`./components/Editor.js`) - The Comment Content Renderer (`./components/CommentContent.js`) -The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API. +The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API. If you check our `index.js` you will notice that we inject this editor in the -`commentBox` slot. We do this to replace the core comment box with this one. +`commentBox` slot. We do this to replace the core comment box with this one. Now, in order to render the new styled comments we need a comment renderer. For this task we will have to replace our core comment renderer by using the diff --git a/plugins/talk-plugin-sort-most-liked/client/translations.yml b/plugins/talk-plugin-sort-most-liked/client/translations.yml index 963561cfd..710519f50 100644 --- a/plugins/talk-plugin-sort-most-liked/client/translations.yml +++ b/plugins/talk-plugin-sort-most-liked/client/translations.yml @@ -12,7 +12,7 @@ de: label: Beliebteste zuerst es: talk-plugin-sort-most-liked: - label: Most liked first + label: Más valoradas primero fr: talk-plugin-sort-most-liked: label: Most liked first diff --git a/plugins/talk-plugin-sort-most-loved/client/translations.yml b/plugins/talk-plugin-sort-most-loved/client/translations.yml index dfb24ff44..f9bf60d18 100644 --- a/plugins/talk-plugin-sort-most-loved/client/translations.yml +++ b/plugins/talk-plugin-sort-most-loved/client/translations.yml @@ -12,7 +12,7 @@ de: label: Häufigste "Ich liebe es" zuerst es: talk-plugin-sort-most-loved: - label: Most loved first + label: Más amadas primero fr: talk-plugin-sort-most-loved: label: Most loved first diff --git a/plugins/talk-plugin-sort-most-replied/client/translations.yml b/plugins/talk-plugin-sort-most-replied/client/translations.yml index 1325d9dd1..2249a5437 100644 --- a/plugins/talk-plugin-sort-most-replied/client/translations.yml +++ b/plugins/talk-plugin-sort-most-replied/client/translations.yml @@ -12,7 +12,7 @@ de: label: Häufigste Antworten zuerst es: talk-plugin-sort-most-replied: - label: Most replied first + label: Más respondidas primero fr: talk-plugin-sort-most-replied: label: Most replied first diff --git a/plugins/talk-plugin-sort-most-respected/client/translations.yml b/plugins/talk-plugin-sort-most-respected/client/translations.yml index 782145237..5ebf85b90 100644 --- a/plugins/talk-plugin-sort-most-respected/client/translations.yml +++ b/plugins/talk-plugin-sort-most-respected/client/translations.yml @@ -12,7 +12,7 @@ de: label: Häufigste "Respektiert" zuerst es: talk-plugin-sort-most-respected: - label: Most respected first + label: Más respetadas primero fr: talk-plugin-sort-most-respected: label: Most respected first diff --git a/plugins/talk-plugin-sort-most-upvoted/client/translations.yml b/plugins/talk-plugin-sort-most-upvoted/client/translations.yml index 7b3c7c29f..6a673a767 100644 --- a/plugins/talk-plugin-sort-most-upvoted/client/translations.yml +++ b/plugins/talk-plugin-sort-most-upvoted/client/translations.yml @@ -1,3 +1,6 @@ en: talk-plugin-sort-most-upvoted: label: Most upvoted first +es: + talk-plugin-sort-oldest: + label: Más votadas primero diff --git a/plugins/talk-plugin-sort-newest/client/translations.yml b/plugins/talk-plugin-sort-newest/client/translations.yml index 294a1a070..663088395 100644 --- a/plugins/talk-plugin-sort-newest/client/translations.yml +++ b/plugins/talk-plugin-sort-newest/client/translations.yml @@ -12,7 +12,7 @@ de: label: Neueste zuerst es: talk-plugin-sort-newest: - label: Newest first + label: Más nuevas primero fr: talk-plugin-sort-newest: label: Newest first diff --git a/plugins/talk-plugin-sort-oldest/client/translations.yml b/plugins/talk-plugin-sort-oldest/client/translations.yml index 7a6cd6e96..5dfe656a1 100644 --- a/plugins/talk-plugin-sort-oldest/client/translations.yml +++ b/plugins/talk-plugin-sort-oldest/client/translations.yml @@ -12,7 +12,7 @@ de: label: Älteste zuerst es: talk-plugin-sort-oldest: - label: Oldest first + label: Más viejas primero fr: talk-plugin-sort-oldest: label: Oldest first diff --git a/plugins/talk-plugin-viewing-options/client/translations.yml b/plugins/talk-plugin-viewing-options/client/translations.yml index 9586263e0..e3516ad7f 100644 --- a/plugins/talk-plugin-viewing-options/client/translations.yml +++ b/plugins/talk-plugin-viewing-options/client/translations.yml @@ -21,8 +21,8 @@ de: es: talk-plugin-viewing-options: viewing_options: "Opciones de visualización" - sort: Sorting - filter: Filtering + sort: Ordenado por + filter: Filtrado por fr: talk-plugin-viewing-options: viewing_options: "Viewing Options" diff --git a/public/css/admin.css b/public/css/admin.css index 50e3109c5..f6891c87e 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -3,16 +3,19 @@ body, #root { height: 100%; margin: 0; background: #fff; + color: #3B4A53; } .container { - max-width: 300px; + max-width: 675px; margin: 50px auto; } #root form { display: none; - padding: 15px; + padding: 15px 0; + /* max-width: 400px; + margin: 0 auto; */ } .legend { @@ -21,6 +24,22 @@ body, #root { font-weight: bold; } +section p, ul { + font-family: Source Sans Pro; + font-style: normal; + font-weight: normal; + line-height: 34px; + font-size: 24px; + letter-spacing: 0.3px; +} + +h1 { + font-family: Source Sans Pro; + font-size: 48px; + font-weight: 600; + color: #000000; +} + label { display: block; margin-top: 10px; @@ -44,28 +63,54 @@ input { } button[type="submit"] { - border-radius: 4px; + font-family: Source Sans Pro; + font-style: normal; + font-weight: 600; + line-height: normal; + font-size: 18px; + text-align: center; + color: white; + + border-radius: 2px; border: none; display: block; - background-color: #333; - color: white; - text-align: center; - width: 100%; - padding: 10px; - margin-top: 10px; + background-color: #3498DB; + margin: 10px auto; + padding: 13px; cursor: pointer; } .error-console { display: none; margin-top: 10px; - border-radius: 4px; - background-color: pink; - color: red; - border: 1px solid red; + border-radius: 2px; + background-color: rgba(242, 101, 99, 0.1); + border: 1px solid #F26563; padding: 10px; } -.error-console.active { - display: block; -} \ No newline at end of file +.error-console span:before { + font-family: 'Material Icons'; + content: '\E000'; + color: #000; + display: inline-block; + vertical-align: sub; + width: 1.4em; +} + +ul.check_list { + list-style-type: none; + margin: 0 0 0 0.5em; +} + +ul.check_list li { + text-indent: -1.4em; +} + +ul.check_list li:before { + font-family: 'Material Icons'; + content: '\E5CA'; + color: #000; + float: left; + width: 1.4em; +} diff --git a/routes/account/index.js b/routes/account/index.js new file mode 100644 index 000000000..70e62accc --- /dev/null +++ b/routes/account/index.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); + +router.get('/email/confirm', (req, res) => { + res.render('account/email/confirm'); +}); + +router.get('/password/reset', (req, res) => { + res.render('account/password/reset'); +}); + +module.exports = router; diff --git a/routes/admin/index.js b/routes/admin/index.js index 66f9c123d..d00ef8642 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,14 +1,6 @@ const express = require('express'); const router = express.Router(); -router.get('/confirm-email', (req, res) => { - res.render('admin/confirm-email'); -}); - -router.get('/password-reset', (req, res) => { - res.render('admin/password-reset'); -}); - router.get('*', (req, res) => { res.render('admin'); }); diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js index 561134885..176728a8c 100644 --- a/routes/api/v1/account.js +++ b/routes/api/v1/account.js @@ -37,7 +37,7 @@ const tokenCheck = (verifier, error, ...whitelistedErrors) => async ( // Log out the error, slurp it and send out the predefined error to the // error handler. console.error(err); - return next(error); + return next(new error()); } res.status(204).end(); @@ -88,7 +88,7 @@ router.post('/password/reset', async (req, res, next) => { locals: { token, }, - subject: 'Password Reset', + subject: res.locals.t('email.password_reset.subject'), email, }); } @@ -109,20 +109,11 @@ router.put( async (req, res, next) => { const { token, password } = req.body; - if (!password || password.length < 8) { - return next(errors.ErrPasswordTooShort); - } - try { - let [user, redirect] = await UsersService.verifyPasswordResetToken(token); - - // Change the users' password. - await UsersService.changePassword(user.id, password); - + const { redirect } = await UsersService.resetPassword(token, password); res.json({ redirect }); - } catch (e) { - console.error(e); - return next(errors.ErrNotAuthorized); + } catch (err) { + return next(err); } } ); diff --git a/routes/api/v1/graph.js b/routes/api/v1/graph.js index 7d13d4c92..40e87415b 100644 --- a/routes/api/v1/graph.js +++ b/routes/api/v1/graph.js @@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions)); if (process.env.NODE_ENV !== 'production') { // Interactive graphiql interface. router.use('/iql', staticTemplate, (req, res) => { - res.render('graphiql', { + res.render('api/graphiql', { endpointURL: 'api/v1/graph/ql', }); }); diff --git a/routes/assets/index.js b/routes/dev/assets.js similarity index 93% rename from routes/assets/index.js rename to routes/dev/assets.js index 35fb6e9ed..cb1f27e43 100644 --- a/routes/assets/index.js +++ b/routes/dev/assets.js @@ -14,7 +14,7 @@ router.get('/id/:asset_id', async (req, res, next) => { return next(errors.ErrNotFound); } - res.render('article', { + res.render('dev/article', { title: asset.title, asset_id: asset.id, asset_url: asset.url, @@ -27,7 +27,7 @@ router.get('/id/:asset_id', async (req, res, next) => { }); router.get('/title/:asset_title', (req, res) => { - return res.render('article', { + return res.render('dev/article', { title: req.params.asset_title.split('-').join(' '), asset_url: '', asset_id: null, @@ -42,7 +42,7 @@ router.get('/', async (req, res, next) => { try { const assets = await Assets.all(skip, limit); - res.render('articles', { + res.render('dev/articles', { assets: assets, }); } catch (err) { diff --git a/routes/dev/index.js b/routes/dev/index.js new file mode 100644 index 000000000..ac72db254 --- /dev/null +++ b/routes/dev/index.js @@ -0,0 +1,25 @@ +const express = require('express'); +const url = require('url'); +const router = express.Router(); + +const { MOUNT_PATH } = require('../../url'); +const SetupService = require('../../services/setup'); +const staticTemplate = require('../../middleware/staticTemplate'); + +router.use('/assets', staticTemplate, require('./assets')); +router.get('/', staticTemplate, async (req, res) => { + try { + await SetupService.isAvailable(); + return res.redirect(url.resolve(MOUNT_PATH, 'admin/install')); + } catch (e) { + return res.render('dev/article', { + title: 'Coral Talk', + asset_url: '', + asset_id: '', + body: '', + basePath: '/static/embed/stream', + }); + } +}); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js index b6e46791e..2dbfd51ff 100644 --- a/routes/index.js +++ b/routes/index.js @@ -75,6 +75,7 @@ router.use(compression()); //============================================================================== router.use('/admin', staticTemplate, require('./admin')); +router.use('/account', staticTemplate, require('./account')); router.use('/login', staticTemplate, require('./login')); router.use('/embed', staticTemplate, require('./embed')); @@ -114,22 +115,14 @@ router.use('/api', require('./api')); //============================================================================== if (process.env.NODE_ENV !== 'production') { - router.use('/assets', staticTemplate, require('./assets')); - router.get('/', staticTemplate, async (req, res) => { - try { - await SetupService.isAvailable(); - return res.redirect('/admin/install'); - } catch (e) { - return res.render('article', { - title: 'Coral Talk', - asset_url: '', - asset_id: '', - body: '', - basePath: '/static/embed/stream', - }); - } + // In development, mount the /dev routes, as well as redirect the root url to + // the development route. + router.use('/dev', require('./dev')); + router.get('/', (req, res) => { + res.redirect(url.resolve(MOUNT_PATH, 'dev'), 302); }); } else { + // In production, optionally redirect to the install if not ran, or the admin. router.get('/', async (req, res, next) => { try { await SetupService.isAvailable(); diff --git a/services/logging.js b/services/logging.js index 7ae3654b1..850283f40 100644 --- a/services/logging.js +++ b/services/logging.js @@ -2,13 +2,13 @@ const { version } = require('../package.json'); const path = require('path'); const { createLogger: createBunyanLogger, stdSerializers } = require('bunyan'); const { LOGGING_LEVEL, REVISION_HASH } = require('../config'); +const debug = require('bunyan-debug-stream'); // Streams enables the ability for development logs to be readable to a human, // but will send JSON logs in production that's parsable by a system like ELK. const streams = (() => { // In development, use the debug stream printer. if (process.env.NODE_ENV !== 'production') { - const debug = require('bunyan-debug-stream'); return [ { level: LOGGING_LEVEL, diff --git a/services/mailer/templates/email-confirm.html.ejs b/services/mailer/templates/email-confirm.html.ejs index 76a8ea47b..396386e21 100644 --- a/services/mailer/templates/email-confirm.html.ejs +++ b/services/mailer/templates/email-confirm.html.ejs @@ -1,3 +1,3 @@

<%= t('email.confirm.has_been_requested') %> <%= email %>.

-

<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>

+

<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>

<%= t('email.confirm.if_you_did_not') %>

diff --git a/services/mailer/templates/email-confirm.txt.ejs b/services/mailer/templates/email-confirm.txt.ejs index b3cf28a01..41fabae46 100644 --- a/services/mailer/templates/email-confirm.txt.ejs +++ b/services/mailer/templates/email-confirm.txt.ejs @@ -4,6 +4,6 @@ <%= t('email.confirm.to_confirm') %> - <%= BASE_URL %>admin/confirm-email#<%= token %> + <%= BASE_URL %>account/email/confirm#<%= token %> <%= t('email.confirm.if_you_did_not') %> diff --git a/services/mailer/templates/password-reset.html.ejs b/services/mailer/templates/password-reset.html.ejs index c0ec4ea46..502781440 100644 --- a/services/mailer/templates/password-reset.html.ejs +++ b/services/mailer/templates/password-reset.html.ejs @@ -1,2 +1,2 @@

<%= t('email.password_reset.we_received_a_request') %>
-<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.

+<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.

diff --git a/services/mailer/templates/password-reset.txt.ejs b/services/mailer/templates/password-reset.txt.ejs index e8db4bab2..2b5e60a9b 100644 --- a/services/mailer/templates/password-reset.txt.ejs +++ b/services/mailer/templates/password-reset.txt.ejs @@ -1,3 +1,3 @@ <%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>: -<%= BASE_URL %>admin/password-reset#<%= token %> +<%= BASE_URL %>account/password/reset#<%= token %> diff --git a/services/migration/helpers.js b/services/migration/helpers.js index 5eb33aadf..49acc78aa 100644 --- a/services/migration/helpers.js +++ b/services/migration/helpers.js @@ -10,8 +10,14 @@ const processUpdates = async (model, updates) => { // Create a new batch operation. const bulk = model.collection.initializeUnorderedBulkOp(); - for (const { query, update } of updates) { - bulk.find(query).updateOne(update); + for (const { query, update, replace } of updates) { + if (update) { + bulk.find(query).updateOne(update); + } else if (replace) { + bulk.find(query).replaceOne(replace); + } else { + throw new Error('invalid update object provided'); + } } // Execute the bulk update operation. diff --git a/services/mongoose.js b/services/mongoose.js index 4e242cf47..167b0d45b 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -66,6 +66,7 @@ if (CREATE_MONGO_INDEXES) { require('../models/action'); require('../models/asset'); require('../models/comment'); + require('../models/migration'); require('../models/setting'); require('../models/user'); require('./migration'); diff --git a/services/users.js b/services/users.js index d2dd60b0b..061fae6fb 100644 --- a/services/users.js +++ b/services/users.js @@ -1,4 +1,5 @@ const uuid = require('uuid'); +const moment = require('moment'); const bcrypt = require('bcryptjs'); const { ErrMaxRateLimit, @@ -17,17 +18,21 @@ const { ErrCannotIgnoreStaff, } = require('../errors'); const { difference, sample, some, merge, random } = require('lodash'); -const { ROOT_URL } = require('../config'); +const { + ROOT_URL, + RECAPTCHA_WINDOW, + RECAPTCHA_INCORRECT_TRIGGER, +} = require('../config'); const { jwt: JWT_SECRET } = require('../secrets'); const debug = require('debug')('talk:services:users'); -const UserModel = require('../models/user'); -const RECAPTCHA_WINDOW = '10m'; // 10 minutes. -const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 5 incorrect attempts, recaptcha will be required. -const ActionsService = require('./actions'); +const User = require('../models/user'); +const Actions = require('./actions'); const mailer = require('./mailer'); const i18n = require('./i18n'); const Wordlist = require('./wordlist'); const DomainList = require('./domain_list'); +const Limit = require('./limit'); +const { get } = require('lodash'); const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; @@ -37,21 +42,20 @@ const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; const SALT_ROUNDS = 10; // Create a redis client to use for authentication. -const Limit = require('./limit'); const loginRateLimiter = new Limit( 'loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, RECAPTCHA_WINDOW ); -// UsersService is the interface for the application to interact with the -// UserModel through. -class UsersService { +// Users is the interface for the application to interact with the +// User through. +class Users { /** * Returns a user (if found) for the given email address. */ static findLocalUser(email) { - return UserModel.findOne({ + return User.findOne({ profiles: { $elemMatch: { id: email.toLowerCase(), @@ -83,7 +87,7 @@ class UsersService { } static async setSuspensionStatus(id, until, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id }, { $set: { @@ -104,7 +108,7 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { throw new ErrNotFound(); } @@ -127,12 +131,12 @@ class UsersService { // Check to see if the user was suspended now and is currently suspended. if (user.suspended && message && message.length > 0) { - await UsersService.sendEmail(user, { + await Users.sendEmail(user, { template: 'plain', locals: { body: message, }, - subject: 'Your account has been suspended', + subject: 'Your account has been suspended', // TODO: replace with translation }); } @@ -140,7 +144,7 @@ class UsersService { } static async setBanStatus(id, status, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id, 'status.banned.status': { @@ -166,7 +170,7 @@ class UsersService { } ); if (!user) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (!user) { throw new ErrNotFound(); } @@ -180,7 +184,7 @@ class UsersService { // Check to see if the user was banned now and is currently banned. if (user.banned && status && message && message.length > 0) { - await UsersService.sendEmail(user, { + await Users.sendEmail(user, { template: 'plain', locals: { body: message, @@ -193,7 +197,7 @@ class UsersService { } static async setUsernameStatus(id, status, assignedBy = null) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id, 'status.username.status': { @@ -217,7 +221,7 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { throw new ErrNotFound(); } @@ -234,57 +238,74 @@ class UsersService { return user; } - static async _setUsername( - id, - username, - fromStatus, - toStatus, - assignedBy, - resetAllowed = false - ) { + static async setUsername(id, username, assignedBy) { try { + const oldestEditTime = moment() + .subtract(14, 'days') + .toDate(); + + // A username can be set if: + // + // - The previous status was 'UNSET' + // - The username has not been changed within the last 14 days. const query = { id, - 'status.username.status': fromStatus, - }; - if (!resetAllowed) { - query.username = { $ne: username }; - } - - let user = await UserModel.findOneAndUpdate( - query, - { - $set: { - username, - lowercaseUsername: username.toLowerCase(), - 'status.username.status': toStatus, + $or: [ + { + 'status.username.status': 'UNSET', }, - $push: { - 'status.username.history': { - status: toStatus, - assigned_by: assignedBy, - created_at: Date.now(), - }, + { + 'status.username.status': { $in: ['APPROVED', 'SET'] }, + $or: [ + { + 'status.username.history.created_at': { + $lte: oldestEditTime, + }, + }, + { + 'status.username.history': [], + }, + { + 'status.username.history': { $exists: false }, + }, + ], + }, + ], + }; + + const update = { + $set: { + username, + lowercaseUsername: username.toLowerCase(), + 'status.username.status': 'SET', + }, + $push: { + 'status.username.history': { + status: 'SET', + assigned_by: assignedBy, + created_at: Date.now(), }, }, - { - new: true, - } - ); + }; + + let user = await User.findOneAndUpdate(query, update, { + new: true, + }); if (!user) { - user = await UsersService.findById(id); + user = await Users.findById(id); if (user === null) { throw new ErrNotFound(); } - if (user.status.username.status !== fromStatus) { + if ( + !['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) || + user.status.username.history.some(({ created_at }) => + moment(created_at).isAfter(oldestEditTime) + ) + ) { throw new ErrPermissionUpdateUsername(); } - if (!resetAllowed && user.username === username) { - throw new ErrSameUsernameProvided(); - } - throw new Error('edit username failed for an unexpected reason'); } @@ -298,25 +319,57 @@ class UsersService { } } - static async setUsername(id, username, assignedBy) { - return UsersService._setUsername( - id, - username, - 'UNSET', - 'SET', - assignedBy, - true - ); - } - static async changeUsername(id, username, assignedBy) { - return UsersService._setUsername( - id, - username, - 'REJECTED', - 'CHANGED', - assignedBy - ); + try { + const query = { + id, + username: { $ne: username }, + 'status.username.status': 'REJECTED', + }; + + const update = { + $set: { + username, + lowercaseUsername: username.toLowerCase(), + 'status.username.status': 'CHANGED', + }, + $push: { + 'status.username.history': { + status: 'CHANGED', + assigned_by: assignedBy, + created_at: Date.now(), + }, + }, + }; + + let user = await User.findOneAndUpdate(query, update, { + new: true, + }); + if (!user) { + user = await Users.findById(id); + if (user === null) { + throw new ErrNotFound(); + } + + if (user.status.username.status !== 'REJECTED') { + throw new ErrPermissionUpdateUsername(); + } + + if (user.username === username) { + throw new ErrSameUsernameProvided(); + } + + throw new Error('edit username failed for an unexpected reason'); + } + + return user; + } catch (err) { + if (err.code === 11000) { + throw new ErrUsernameTaken(); + } + + throw err; + } } /** @@ -340,7 +393,7 @@ class UsersService { * Sets or removes the recaptcha_required flag on a user's local profile. */ static flagForRecaptchaRequirement(email, required) { - return UserModel.update( + return User.update( { profiles: { $elemMatch: { @@ -372,11 +425,11 @@ class UsersService { const GROUP_ATTEMPTS = 50; // Cast the original username. - const castedName = UsersService.castUsername(username); + const castedName = Users.castUsername(username); const lowercaseUsername = castedName.toLowerCase(); // Try to see if our first guess has been taken. - const existingUserWithName = await UserModel.findOne({ + const existingUserWithName = await User.findOne({ lowercaseUsername, }); if (!existingUserWithName) { @@ -396,7 +449,7 @@ class UsersService { ); // See if any of these users aren't taken already. - const existingUsernames = (await UserModel.find( + const existingUsernames = (await User.find( { lowercaseUsername: { $in: lowercaseUsernameGuesses }, }, @@ -432,7 +485,7 @@ class UsersService { * @param {Function} done [description] */ static async findOrCreateExternalUser(ctx, id, provider, displayName) { - let user = await UserModel.findOne({ + let user = await User.findOne({ profiles: { $elemMatch: { id, @@ -447,10 +500,10 @@ class UsersService { // User does not exist and need to be created. // Create an initial username for the user. - let username = await UsersService.getInitialUsername(displayName); + let username = await Users.getInitialUsername(displayName); // The user was not found, lets create them! - user = new UserModel({ + user = new User({ username, lowercaseUsername: username.toLowerCase(), profiles: [{ id, provider }], @@ -480,11 +533,7 @@ class UsersService { * @param {String} email the email for the user to send the email to */ static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) { - let token = await UsersService.createEmailConfirmToken( - user, - email, - redirectURI - ); + let token = await Users.createEmailConfirmToken(user, email, redirectURI); return mailer.send({ template: 'email-confirm', @@ -507,9 +556,13 @@ class UsersService { } static async changePassword(id, password) { - const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } - return UserModel.update( + const hashedPassword = await Users.hashPassword(password); + + return User.update( { id }, { $inc: { __v: 1 }, @@ -580,13 +633,13 @@ class UsersService { username = username.trim(); await Promise.all([ - UsersService.isValidUsername(username), - UsersService.isValidPassword(password), + Users.isValidUsername(username), + Users.isValidPassword(password), ]); - const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + const hashedPassword = await Users.hashPassword(password); - let user = new UserModel({ + let user = new User({ username, lowercaseUsername: username.toLowerCase(), password: hashedPassword, @@ -630,11 +683,7 @@ class UsersService { * @param {String} role role to add */ static setRole(id, role) { - return UserModel.update( - { id }, - { $set: { role } }, - { runValidators: true } - ); + return User.update({ id }, { $set: { role } }, { runValidators: true }); } /** @@ -642,7 +691,7 @@ class UsersService { * @param {String} id user id (uuid) */ static findById(id) { - return UserModel.findOne({ id }); + return User.findOne({ id }); } /** @@ -652,7 +701,7 @@ class UsersService { */ static async findOrCreateByIDToken(id, token) { // Try to get the user. - let user = await UserModel.findOne({ id }); + let user = await User.findOne({ id }); // If the user was not found, try to look it up. if (user === null) { @@ -669,7 +718,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findByIdArray(ids) { - return UserModel.find({ + return User.find({ id: { $in: ids }, }); } @@ -679,7 +728,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findPublicByIdArray(ids) { - return UserModel.find( + return User.find( { id: { $in: ids }, }, @@ -699,7 +748,7 @@ class UsersService { email = email.toLowerCase(); const [user, domainValidated] = await Promise.all([ - UserModel.findOne({ profiles: { $elemMatch: { id: email } } }), + User.findOne({ profiles: { $elemMatch: { id: email } } }), DomainList.urlCheck(loc), ]); if (!user) { @@ -746,28 +795,53 @@ class UsersService { }); } - /** - * Verifies a jwt and returns the associated user. Throws an error when the - * token isn't valid. - * - * @param {String} token the JSON Web Token to verify - */ + // TODO: update doc static async verifyPasswordResetToken(token) { if (!token) { throw new Error('cannot verify an empty token'); } - const { userId, loc, version } = await UsersService.verifyToken(token, { + const { userId, loc: redirect, version } = await Users.verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT, }); - const user = await UsersService.findById(userId); + const user = await Users.findById(userId); if (version !== user.__v) { throw new Error('password reset token has expired'); } - return [user, loc]; + return { user, redirect, version }; + } + + static async hashPassword(password) { + return bcrypt.hash(password, SALT_ROUNDS); + } + + // TODO: update doc + static async resetPassword(token, password) { + const { user, redirect, version } = await this.verifyPasswordResetToken( + token + ); + + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + + const hashedPassword = await Users.hashPassword(password); + + // Update the user's password. + await User.update( + { id: user.id, __v: version }, + { + $inc: { __v: 1 }, + $set: { + password: hashedPassword, + }, + } + ); + + return { user, redirect }; } /** @@ -775,7 +849,7 @@ class UsersService { * @return {Promise} */ static count(query = {}) { - return UserModel.count(query); + return User.count(query); } /** @@ -783,7 +857,7 @@ class UsersService { * @return {Promise} */ static all() { - return UserModel.find(); + return User.find(); } /** @@ -791,7 +865,7 @@ class UsersService { * @return {Promise} */ static updateSettings(id, settings) { - return UserModel.update( + return User.update( { id, }, @@ -811,7 +885,7 @@ class UsersService { * @return {Promise} */ static addAction(item_id, user_id, action_type, metadata) { - return ActionsService.create({ + return Actions.create({ item_id, item_type: 'users', user_id, @@ -874,11 +948,11 @@ class UsersService { throw new Error('cannot verify an empty token'); } - const decoded = await UsersService.verifyToken(token, { + const decoded = await Users.verifyToken(token, { subject: EMAIL_CONFIRM_JWT_SUBJECT, }); - const user = await UserModel.findOne({ + const user = await User.findOne({ id: decoded.userID, profiles: { $elemMatch: { @@ -896,7 +970,9 @@ class UsersService { throw new ErrNotFound(); } - if (profile.metadata && profile.metadata.confirmed_at !== null) { + // Check to see if the profile has already been confirmed. + const confirmedAt = get(profile, 'metadata.confirmed_at', null); + if (confirmedAt && confirmedAt < Date.now()) { throw new ErrEmailAlreadyVerified(); } @@ -911,13 +987,11 @@ class UsersService { * @return {Promise} */ static async verifyEmailConfirmation(token) { - let { - userID, - email, - referer, - } = await UsersService.verifyEmailConfirmationToken(token); + let { userID, email, referer } = await Users.verifyEmailConfirmationToken( + token + ); - await UsersService.confirmEmail(userID, email); + await Users.confirmEmail(userID, email); return { userID, email, referer }; } @@ -926,7 +1000,7 @@ class UsersService { * Marks the email on the user as confirmed. */ static confirmEmail(id, email) { - return UserModel.update( + return User.update( { id, profiles: { @@ -954,12 +1028,12 @@ class UsersService { throw new Error('Users cannot ignore themselves'); } - const users = await UsersService.findByIdArray(usersToIgnore); + const users = await Users.findByIdArray(usersToIgnore); if (some(users, user => user.isStaff())) { throw new ErrCannotIgnoreStaff(); } - return UserModel.update( + return User.update( { id }, { $addToSet: { @@ -977,7 +1051,7 @@ class UsersService { * @param {Array} usersToStopIgnoring Array of user IDs to stop ignoring */ static async stopIgnoringUsers(id, usersToStopIgnoring) { - await UserModel.update( + await User.update( { id }, { $pullAll: { @@ -988,7 +1062,7 @@ class UsersService { } } -module.exports = UsersService; +module.exports = Users; // Extract all the tokenUserNotFound plugins so we can integrate with other // providers. diff --git a/test/e2e/globals.js b/test/e2e/globals.js index 56bcd3868..aeecd204e 100644 --- a/test/e2e/globals.js +++ b/test/e2e/globals.js @@ -27,5 +27,6 @@ module.exports = { body: 'This is a test comment', }, organizationName: 'Coral', + organizationContactEmail: 'coral@coralproject.net', }, }; diff --git a/test/e2e/page_objects/embedStream.js b/test/e2e/page_objects/embedStream.js index be60fc8d0..de3a29973 100644 --- a/test/e2e/page_objects/embedStream.js +++ b/test/e2e/page_objects/embedStream.js @@ -28,7 +28,7 @@ module.exports = { return this.section.comments; }, navigateToAsset: function(asset) { - this.api.url(`${this.api.launchUrl}/assets/title/${asset}`); + this.api.url(`${this.api.launchUrl}/dev/assets/title/${asset}`); return this; }, switchToIframe: function() { @@ -44,7 +44,7 @@ module.exports = { }, ], url: function() { - return this.api.launchUrl; + return this.api.launchUrl + '/dev/'; }, elements: { iframe: `#${iframeId}`, diff --git a/test/e2e/page_objects/install.js b/test/e2e/page_objects/install.js index 26b024993..1f27eabd7 100644 --- a/test/e2e/page_objects/install.js +++ b/test/e2e/page_objects/install.js @@ -20,6 +20,8 @@ module.exports = { selector: '.talk-install-step-2', elements: { organizationNameInput: '.talk-install-step-2 #organizationName', + organizationContactEmailInput: + '.talk-install-step-2 #organizationContactEmail', saveButton: '.talk-install-step-2-save-button', }, }, diff --git a/test/e2e/specs/01_install.js b/test/e2e/specs/01_install.js index a8a88566c..75e6a60eb 100644 --- a/test/e2e/specs/01_install.js +++ b/test/e2e/specs/01_install.js @@ -38,7 +38,12 @@ module.exports = { step2 .waitForElementVisible('@organizationNameInput') + .waitForElementVisible('@organizationContactEmailInput', 5000) .setValue('@organizationNameInput', testData.organizationName) + .setValue( + '@organizationContactEmailInput', + testData.organizationContactEmail + ) .waitForElementVisible('@saveButton') .click('@saveButton'); }, diff --git a/test/server/services/users.js b/test/server/services/users.js index e8a0172f7..2b9210f83 100644 --- a/test/server/services/users.js +++ b/test/server/services/users.js @@ -2,6 +2,8 @@ const UsersService = require('../../../services/users'); const SettingsService = require('../../../services/settings'); const mailer = require('../../../services/mailer'); const Context = require('../../../graph/context'); +const timekeeper = require('timekeeper'); +const moment = require('moment'); const chai = require('chai'); chai.use(require('chai-as-promised')); @@ -302,6 +304,62 @@ describe('services.UsersService', () => { await UsersService[func](user.id, user.username); } }); + + if (func === 'setUsername') { + it('should let a user set their username from UNSET', async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, 'UNSET'); + await UsersService.setUsername(user.id, 'new_username', null); + }); + + describe('time based', () => { + afterEach(() => { + timekeeper.reset(); + }); + + ['SET', 'APPROVED'].forEach(status => { + it(`should not allow users to change their username if it was changed within 14 of today from ${status}`, async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, status); + + timekeeper.travel( + moment() + .add(5, 'days') + .toDate() + ); + + try { + await UsersService.setUsername(user.id, 'new_username', null); + throw new Error('edit was processed successfully'); + } catch (err) { + expect(err).have.property( + 'translation_key', + 'EDIT_USERNAME_NOT_AUTHORIZED' + ); + } + }); + + it(`allows users to change their username if it was changed 14 days before today from ${status}`, async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, status); + + timekeeper.travel( + moment() + .add(15, 'days') + .toDate() + ); + + await UsersService.setUsername(user.id, 'new_username', null); + }); + }); + }); + } }); }); diff --git a/views/admin/confirm-email.ejs b/views/account/email/confirm.ejs similarity index 66% rename from views/admin/confirm-email.ejs rename to views/account/email/confirm.ejs index 4e59de1d8..47bc7773b 100644 --- a/views/admin/confirm-email.ejs +++ b/views/account/email/confirm.ejs @@ -1,19 +1,19 @@ - - Email Verification - - - <%- include ../partials/head %> + <%= t('confirm_email.email_confirmation') %> + <%- include ../../partials/account %>
-
-
- <%= t('confirm_email.click_to_confirm') %> - -
+
+

<%= t('confirm_email.email_confirmation') %>

+

<%= t('confirm_email.click_to_confirm') %>

+
+
+ +
+