diff --git a/.circleci/config.yml b/.circleci/config.yml index 2fe14ad6e..848d496e7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,27 @@ + +# job_environment will setup the environment for any job being executed. +job_environment: &job_environment + NODE_ENV: test + DISABLE_CREATE_MONGO_INDEXES: TRUE + # job_defaults applies all the defaults for each job. job_defaults: &job_defaults working_directory: ~/coralproject/talk docker: - image: circleci/node:8 + environment: + <<: *job_environment + +# create_indexes will create the mongo indexes and wait until they have been +# built. +create_indexes: &create_indexes + run: + name: Create the database indexes and wait until they are built + command: ./bin/cli db createIndexes # integration_environment is the environment that configures the tests. integration_environment: &integration_environment - NODE_ENV: test + <<: *job_environment CIRCLE_TEST_REPORTS: /tmp/circleci-test-results E2E_MAX_RETRIES: 3 @@ -25,6 +40,7 @@ integration_job: &integration_job - checkout - attach_workspace: at: ~/coralproject/talk + - <<: *create_indexes - run: name: Setup the database with defaults command: ./bin/cli setup --defaults @@ -117,6 +133,7 @@ jobs: environment: JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml JEST_REPORTER: jest-junit + - <<: *create_indexes - run: name: Run the server unit tests command: yarn test:server diff --git a/.eslintrc.json b/.eslintrc.json index 78f7c2397..d00d21106 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,6 @@ { + "env": { + "jest": true + }, "extends": "@coralproject/eslint-config-talk" } diff --git a/.gitignore b/.gitignore index 1235e0db0..0cab02aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ 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-slack-notifications 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/app.js b/app.js index c2bf9648d..c5507da2e 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,6 @@ const express = require('express'); -const morgan = require('morgan'); +const trace = require('./middleware/trace'); +const logging = require('./middleware/logging'); const path = require('path'); const merge = require('lodash/merge'); const helmet = require('helmet'); @@ -12,6 +13,10 @@ const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config'); const app = express(); +// Add the trace middleware first, it will create a request ID for each request +// downstream. +app.use(trace); + //============================================================================== // PLUGIN PRE APPLICATION MIDDLEWARE //============================================================================== @@ -30,7 +35,7 @@ plugins.get('server', 'app').forEach(({ plugin, app: callback }) => { // Add the logging middleware only if we aren't testing. if (process.env.NODE_ENV !== 'test') { - app.use(morgan('dev')); + app.use(logging.log); } if (ENABLE_TRACING && APOLLO_ENGINE_KEY) { diff --git a/bin/cli b/bin/cli index 314f33dd4..df2253261 100755 --- a/bin/cli +++ b/bin/cli @@ -11,6 +11,7 @@ const Matcher = require('did-you-mean'); program .command('serve', 'serve the application') + .command('db', 'run database commands') .command('settings', 'interact with the application settings') .command('assets', 'interact with assets') .command('setup', 'setup the application') diff --git a/bin/cli-db b/bin/cli-db new file mode 100755 index 000000000..1057b1057 --- /dev/null +++ b/bin/cli-db @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +const util = require('./util'); +const program = require('commander'); +const config = require('../config'); + +async function createIndexes() { + try { + // Ensure we enable the index creation. + config.CREATE_MONGO_INDEXES = true; + + // TODO: handle the plugin index creation? + + // Let's register the shutdown hooks. + util.onshutdown([() => require('../services/mongoose').disconnect()]); + + // Lets create all the database indexes for the application and wait for all + // them to finish their indexing. + const models = [ + require('../models/action'), + require('../models/asset'), + require('../models/comment'), + require('../models/setting'), + require('../models/user'), + require('../models/migration'), + ]; + + // Call the `.init()` method to setup all the indexes on each model. + // `init()` returns a promise that resolves when the indexes have finished + // building successfully. The `init()` function is idempotent, so we don't + // have to worry about triggering an index rebuild. + await Promise.all(models.map(Model => Model.init())); + + console.log('Indexes created'); + util.shutdown(0); + } catch (err) { + console.error(err); + util.shutdown(1); + } +} + +program + .command('createIndexes') + .description('creates the database indexes and waits until they are created') + .action(createIndexes); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (process.argv.length <= 2) { + program.outputHelp(); + util.shutdown(); +} diff --git a/bin/cli-setup b/bin/cli-setup index 572e8c7fc..11da478c2 100755 --- a/bin/cli-setup +++ b/bin/cli-setup @@ -14,7 +14,7 @@ const SettingsService = require('../services/settings'); const SetupService = require('../services/setup'); const UsersService = require('../services/users'); const MigrationService = require('../services/migration'); -const errors = require('../errors'); +const { ErrSettingsInit, ErrSettingsNotInit } = require('../errors'); const Context = require('../graph/context'); // Register the shutdown criteria. @@ -41,13 +41,15 @@ const performSetup = async () => { // We should NOT have gotten a settings object, this means that the // application is already setup. Error out here. - throw errors.ErrSettingsInit; - } catch (e) { + throw new ErrSettingsInit(); + } catch (err) { // If the error is `not init`, then we're good, otherwise, it's something // else. - if (e !== errors.ErrSettingsNotInit) { - throw e; + if (err instanceof ErrSettingsNotInit) { + return; } + + throw err; } if (program.defaults) { 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/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/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 16c2bd2f7..a46d2dccd 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -5,14 +5,7 @@ export const singleView = () => ({ type: actions.SINGLE_VIEW }); // hide shortcuts note export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => { - try { - if (localStorage) { - localStorage.setItem('coral:shortcutsNote', 'hide'); - } - } catch (e) { - // above will fail in Safari private mode - } - + localStorage.setItem('coral:shortcutsNote', 'hide'); dispatch({ type: actions.HIDE_SHORTCUTS_NOTE }); }; diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 31ad999dc..e45e3f095 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -3,7 +3,7 @@ import cn from 'classnames'; import PropTypes from 'prop-types'; import capitalize from 'lodash/capitalize'; import styles from './UserDetail.css'; -import AccountHistory from './AccountHistory'; +import UserHistory from './UserHistory'; import { Slot } from 'coral-framework/components'; import UserDetailCommentList from '../components/UserDetailCommentList'; import { @@ -28,26 +28,6 @@ import UserInfoTooltip from './UserInfoTooltip'; import t from 'coral-framework/services/i18n'; class UserDetail extends React.Component { - rejectThenReload = async info => { - await this.props.rejectComment(info); - this.props.data.refetch(); - }; - - acceptThenReload = async info => { - await this.props.acceptComment(info); - this.props.data.refetch(); - }; - - bulkAcceptThenReload = async () => { - await this.props.bulkAccept(); - this.props.data.refetch(); - }; - - bulkRejectThenReload = async () => { - await this.props.bulkReject(); - this.props.data.refetch(); - }; - changeTab = tab => { this.props.changeTab(tab); }; @@ -110,8 +90,14 @@ class UserDetail extends React.Component { unbanUser, unsuspendUser, modal, + acceptComment, + rejectComment, + bulkAccept, + bulkReject, } = this.props; + console.log(rejectedComments, totalComments); + // if totalComments is 0, you're dividing by zero let rejectedPercent = rejectedComments / totalComments * 100; @@ -286,7 +272,7 @@ class UserDetail extends React.Component { 'talk-admin-user-detail-history-tab' )} > - {t('user_detail.account_history')} + {t('user_detail.user_history')} @@ -304,12 +290,12 @@ class UserDetail extends React.Component { loadMore={loadMore} toggleSelect={toggleSelect} viewUserDetail={viewUserDetail} - acceptComment={this.acceptThenReload} - rejectComment={this.rejectThenReload} + acceptComment={acceptComment} + rejectComment={rejectComment} selectedCommentIds={selectedCommentIds} toggleSelectAll={toggleSelectAll} - bulkAcceptThenReload={this.bulkAcceptThenReload} - bulkRejectThenReload={this.bulkRejectThenReload} + bulkAcceptThenReload={bulkAccept} + bulkRejectThenReload={bulkReject} /> - + diff --git a/client/coral-admin/src/components/AccountHistory.css b/client/coral-admin/src/components/UserHistory.css similarity index 100% rename from client/coral-admin/src/components/AccountHistory.css rename to client/coral-admin/src/components/UserHistory.css diff --git a/client/coral-admin/src/components/AccountHistory.js b/client/coral-admin/src/components/UserHistory.js similarity index 73% rename from client/coral-admin/src/components/AccountHistory.js rename to client/coral-admin/src/components/UserHistory.js index 0d85b62af..3a3472c86 100644 --- a/client/coral-admin/src/components/AccountHistory.js +++ b/client/coral-admin/src/components/UserHistory.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { murmur3 } from 'murmurhash-js'; -import styles from './AccountHistory.css'; +import styles from './UserHistory.css'; import cn from 'classnames'; import flatten from 'lodash/flatten'; import orderBy from 'lodash/orderBy'; @@ -43,15 +43,15 @@ const readableDuration = (startDate, endDate) => { const buildActionResponse = (typename, created_at, until, status) => { switch (typename) { case 'UsernameStatusHistory': - return t('account_history.username_status', status); + return t('user_history.username_status', status); case 'BannedStatusHistory': return status - ? t('account_history.user_banned') - : t('account_history.ban_removed'); + ? t('user_history.user_banned') + : t('user_history.ban_removed'); case 'SuspensionStatusHistory': return until - ? t('account_history.suspended', readableDuration(created_at, until)) - : t('account_history.suspension_removed'); + ? t('user_history.suspended', readableDuration(created_at, until)) + : t('user_history.suspension_removed'); default: return '-'; } @@ -62,43 +62,41 @@ const getModerationValue = assignedBy => assignedBy.username ) : ( - {t('account_history.system')} + {t('user_history.system')} ); -class AccountHistory extends React.Component { +class UserHistory extends React.Component { render() { const { user } = this.props; const userHistory = buildUserHistory(user.state); return (
-
+
+
{t('user_history.date')}
- {t('account_history.date')} + {t('user_history.action')}
- {t('account_history.action')} -
-
- {t('account_history.taken_by')} + {t('user_history.taken_by')}
{userHistory.map( ({ __typename, created_at, assigned_by, until, status }) => (
{moment(new Date(created_at)).format('MMM DD, YYYY')} @@ -107,7 +105,7 @@ class AccountHistory extends React.Component { className={cn( styles.item, styles.action, - 'talk-admin-account-history-row-status' + 'talk-admin-user-history-row-status' )} > {buildActionResponse(__typename, created_at, until, status)} @@ -116,7 +114,7 @@ class AccountHistory extends React.Component { className={cn( styles.item, styles.username, - 'talk-admin-account-history-row-assigned-by' + 'talk-admin-user-history-row-assigned-by' )} > {getModerationValue(assigned_by)} @@ -130,8 +128,8 @@ class AccountHistory extends React.Component { } } -AccountHistory.propTypes = { +UserHistory.propTypes = { user: PropTypes.object.isRequired, }; -export default AccountHistory; +export default UserHistory; diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index fb1508a2f..360819dd6 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -148,6 +148,7 @@ UserDetailContainer.propTypes = { selectedCommentIds: PropTypes.array, unbanUser: PropTypes.func.isRequired, unsuspendUser: PropTypes.func.isRequired, + userId: PropTypes.string, }; const LOAD_MORE_QUERY = gql` @@ -245,7 +246,6 @@ export const withUserDetailQuery = withQuery( options: ({ userId, statuses }) => { return { variables: { author_id: userId, statuses }, - fetchPolicy: 'network-only', }; }, skip: ownProps => !ownProps.userId, diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 0cf0a8cf9..158870dec 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -15,7 +15,8 @@ import { hideShortcutsNote } from './actions/moderation'; smoothscroll.polyfill(); function init({ store, localStorage }) { - if (localStorage && localStorage.getItem('coral:shortcutsNote') === 'hide') { + const shouldHide = localStorage.getItem('coral:shortcutsNote') === 'hide'; + if (shouldHide) { store.dispatch(hideShortcutsNote()); } } 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..f3daeffc2 --- /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 = { organizationEmail: { $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} /> + + + + + {t('talk-plugin-auth.change_password.cancel')} + +
+ ) : ( +
+ +
+ )} + + ); + } +} + +ChangePassword.propTypes = { + changePassword: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, +}; + +export default ChangePassword; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.css b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.css new file mode 100644 index 000000000..5b4631ecf --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.css @@ -0,0 +1,122 @@ +.container { + margin-bottom: 20px; + display: flex; + position: relative; + color: #202020; + padding: 10px; + border-radius: 2px; + box-sizing: border-box; + justify-content: space-between; + + &.editing { + background-color: #EDEDED; + } +} + +.content { + flex-grow: 1; +} + +.actions { + flex-grow: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.email { + margin: 0; +} + +.username { + 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-auth/client/profile-settings/components/ChangeUsername.js b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.js new file mode 100644 index 000000000..8188bdfbe --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.js @@ -0,0 +1,188 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './ChangeUsername.css'; +import { Button } from 'plugin-api/beta/client/components/ui'; +import ChangeUsernameDialog from './ChangeUsernameDialog'; +import { t } from 'plugin-api/beta/client/services'; +import InputField from './InputField'; +import { getErrorMessages } from 'coral-framework/utils'; +import { canUsernameBeUpdated } from 'coral-framework/utils/user'; + +const initialState = { + editing: false, + showDialog: false, + formData: {}, +}; + +class ChangeUsername 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 () => { + this.showDialog(); + }; + + saveChanges = async () => { + const { newUsername } = this.state.formData; + const { changeUsername } = this.props; + + try { + await changeUsername(newUsername); + this.props.notify( + 'success', + t('talk-plugin-auth.change_username.changed_username_success_msg') + ); + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + } + + this.clearForm(); + this.disableEditing(); + }; + + onChange = e => { + const { name, value } = e.target; + + this.setState(state => ({ + formData: { + ...state.formData, + [name]: value, + }, + })); + }; + + closeDialog = () => { + this.setState({ + showDialog: false, + }); + }; + + render() { + const { + username, + emailAddress, + root: { me: { state: { status } } }, + notify, + } = this.props; + const { editing, formData, showDialog } = this.state; + + return ( +
+ + + {editing ? ( +
+
+ + + {t('talk-plugin-auth.change_username.change_username_note')} + + + +
+
+ ) : ( +
+

{username}

+ {emailAddress ? ( +

{emailAddress}

+ ) : null} +
+ )} + {editing ? ( +
+ + + {t('talk-plugin-auth.change_username.cancel')} + +
+ ) : ( +
+ +
+ )} +
+ ); + } +} + +ChangeUsername.propTypes = { + root: PropTypes.object.isRequired, + changeUsername: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + username: PropTypes.string, + emailAddress: PropTypes.string, +}; + +export default ChangeUsername; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.css b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.css new file mode 100644 index 000000000..af681d596 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/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-auth/client/profile-settings/components/ChangeUsernameDialog.js b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.js new file mode 100644 index 000000000..321b36926 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.js @@ -0,0 +1,117 @@ +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-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-auth.change_username.confirm_username_change')} +

+
+

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

+
+ + {t('talk-plugin-auth.change_username.old_username')}:{' '} + {this.props.username} + + + {t('talk-plugin-auth.change_username.new_username')}:{' '} + {this.props.formData.newUsername} + +
+
+ + + {t('talk-plugin-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-auth/client/profile-settings/components/ErrorMessage.css b/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css new file mode 100644 index 000000000..abcf692fe --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/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-auth/client/profile-settings/components/ErrorMessage.js b/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.js new file mode 100644 index 000000000..f39a8fc08 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/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-auth/client/profile-settings/components/InputField.css b/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css new file mode 100644 index 000000000..cd6015e47 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/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; +} \ No newline at end of file diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js b/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js new file mode 100644 index 000000000..34c314c20 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/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-auth/client/profile-settings/containers/ChangePassword.js b/plugins/talk-plugin-auth/client/profile-settings/containers/ChangePassword.js new file mode 100644 index 000000000..1322a2940 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/containers/ChangePassword.js @@ -0,0 +1,12 @@ +import { compose } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { connect } from 'plugin-api/beta/client/hocs'; +import ChangePassword from '../components/ChangePassword'; +import { notify } from 'coral-framework/actions/notification'; +import { withChangePassword } from 'plugin-api/beta/client/hocs'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +export default compose(connect(null, mapDispatchToProps), withChangePassword)( + ChangePassword +); diff --git a/plugins/talk-plugin-auth/client/profile-settings/containers/ChangeUsername.js b/plugins/talk-plugin-auth/client/profile-settings/containers/ChangeUsername.js new file mode 100644 index 000000000..87e1e18b5 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/containers/ChangeUsername.js @@ -0,0 +1,12 @@ +import { compose } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { connect } from 'plugin-api/beta/client/hocs'; +import ChangeUsername from '../components/ChangeUsername'; +import { notify } from 'coral-framework/actions/notification'; +import { withChangeUsername } from 'plugin-api/beta/client/hocs'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +export default compose(connect(null, mapDispatchToProps), withChangeUsername)( + ChangeUsername +); diff --git a/plugins/talk-plugin-auth/client/translations.yml b/plugins/talk-plugin-auth/client/translations.yml index 4bd09e890..00593c9e0 100644 --- a/plugins/talk-plugin-auth/client/translations.yml +++ b/plugins/talk-plugin-auth/client/translations.yml @@ -58,8 +58,9 @@ da: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" + password_error: "Password must be at least 8 characters." forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" register: "Register" @@ -101,8 +102,9 @@ en: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" + password_error: "Password must be at least 8 characters." forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" register: "Register" @@ -131,6 +133,29 @@ en: username: Username write_your_username: "Edit your username" your_username: "Your username appears on every comment you post." + 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" + 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" + 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 be changed every 14 days" de: talk-plugin-auth: login: @@ -192,6 +217,7 @@ es: or: "O" email: "Dirección de Correo" password: "Contraseña" + password_error: "La contraseña debe tener al menos 8 caracteres." forgot_your_pass: "¿Has olvidado tu contraseña?" need_an_account: "¿Necesitas una cuenta?" register: "Registrar" @@ -222,6 +248,29 @@ es: username: Nombre write_your_username: "Edita tu nombre" your_username: "Tu nombre aparece en cada comentario que publiques." + 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" + change_username: + change_username_note: "El usuario puede ser cambiado cada 14 días" + save: "Guardar" + edit_profile: "Editar Perfil" + cancel: "Cancelar" + confirm_username_change: "Confirmar Cambio de Usuario" + description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios." + old_username: "Usuario viejo" + new_username: "Usuario nuevo" + bottom_note: "Nota: No podrás cambiar tu usuario por 14 días" + confirm_changes: "Confirmar Cambios" + username_does_not_match: "El usuario no coincide" + changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días." + change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días." fr: talk-plugin-auth: login: @@ -367,7 +416,7 @@ pt_BR: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" diff --git a/plugins/talk-plugin-notifications-category-reply/index.js b/plugins/talk-plugin-notifications-category-reply/index.js index e214975ac..cce258daa 100644 --- a/plugins/talk-plugin-notifications-category-reply/index.js +++ b/plugins/talk-plugin-notifications-category-reply/index.js @@ -1,4 +1,4 @@ -const { get } = require('lodash'); +const { get, map } = require('lodash'); const path = require('path'); const handle = async (ctx, comment) => { @@ -23,6 +23,9 @@ const handle = async (ctx, comment) => { id user { id + ignoredUsers { + id + } notificationSettings { onReply } @@ -53,13 +56,23 @@ const handle = async (ctx, comment) => { return; } + // Pull out the author of the new comment. + const authorID = get(comment, 'author_id'); + // Check to see if this is yourself replying to yourself, if that's the case // don't send a notification. - if (userID === get(comment, 'author_id')) { + if (userID === authorID) { ctx.log.info('user id of parent comment is the same as the new comment'); return; } + // Check to see if this user is ignoring the user who replied to their + // comment. + if (map(get(comment, 'user.ignoredUsers', []), 'id').indexOf(authorID)) { + ctx.log.info('parent user has ignored the author of the new comment'); + return; + } + // The user does have notifications for replied comments enabled, queue the // notification to be sent. return { userID, date: comment.created_at, context: comment.id }; diff --git a/plugins/talk-plugin-notifications/server/mutators.js b/plugins/talk-plugin-notifications/server/mutators.js index 5c0db9c8d..46954faa5 100644 --- a/plugins/talk-plugin-notifications/server/mutators.js +++ b/plugins/talk-plugin-notifications/server/mutators.js @@ -29,10 +29,11 @@ async function updateNotificationSettings(ctx, settings) { } module.exports = ctx => { + const { connectors: { errors: ErrNotAuthorized } } = ctx; + let mutators = { User: { - updateNotificationSettings: () => - Promise.reject(ctx.connectors.errors.ErrNotAuthorized), + updateNotificationSettings: () => Promise.reject(new ErrNotAuthorized()), }, }; 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..1d019a79b --- /dev/null +++ b/plugins/talk-plugin-profile-data/README.md @@ -0,0 +1,24 @@ +--- +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 premod. 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/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..06fa1fa89 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js @@ -0,0 +1,74 @@ +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'; + +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, + root: PropTypes.object.isRequired, + }; + + render() { + const { + root: { me: { lastAccountDownload } }, + requestDownloadLink, + } = this.props; + + const now = new Date(); + const lastAccountDownloadDate = + lastAccountDownload && new Date(lastAccountDownload); + const hoursLeft = lastAccountDownloadDate + ? Math.ceil( + 7 * 24 - (now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6 + ) + : 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')}. +

+ {lastAccountDownloadDate && ( +

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

+ )} + {canRequestDownload ? ( + + ) : ( + + )} +
+ ); + } +} + +export default DownloadCommentHistory; 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..96dbf6975 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { compose, gql } from 'react-apollo'; +import DownloadCommentHistory from '../components/DownloadCommentHistory'; +import { withFragments } from 'plugin-api/beta/client/hocs'; +import { withRequestDownloadLink } from '../mutations'; + +class DownloadCommentHistoryContainer extends Component { + static propTypes = { + requestDownloadLink: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, + }; + + render() { + return ( + + ); + } +} + +const enhance = compose( + 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..b24c31568 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/graphql.js @@ -0,0 +1,18 @@ +import update from 'immutability-helper'; + +export default { + mutations: { + DownloadCommentHistory: () => ({ + updateQueries: { + CoralEmbedStream_Profile: previousData => + update(previousData, { + me: { + lastAccountDownload: { + $set: new Date().toISOString(), + }, + }, + }), + }, + }), + }, +}; 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..fee9f2129 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/index.js @@ -0,0 +1,11 @@ +import DownloadCommentHistory from './containers/DownloadCommentHistory'; +import translations from './translations.yml'; +import graphql from './graphql'; + +export default { + slots: { + profileSettings: [DownloadCommentHistory], + }, + translations, + ...graphql, +}; diff --git a/plugins/talk-plugin-profile-data/client/mutations.js b/plugins/talk-plugin-profile-data/client/mutations.js new file mode 100644 index 000000000..a370c9ff0 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/mutations.js @@ -0,0 +1,19 @@ +import { withMutation } from 'plugin-api/beta/client/hocs'; +import { gql } from 'react-apollo'; + +export const withRequestDownloadLink = withMutation( + gql` + mutation DownloadCommentHistory { + requestDownloadLink { + errors { + translation_key + } + } + } + `, + { + props: ({ mutate }) => ({ + requestDownloadLink: () => mutate({ variables: {} }), + }), + } +); 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..ee6dc4e51 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/translations.yml @@ -0,0 +1,12 @@ +en: + download_request: + section_title: "Download My Comment History" + you_will_get_a_copy: "You will recieve an email with a link to download your comment history. You can make" + download_rate: "one download request every 7 days" + 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" 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..ed5a29050 --- /dev/null +++ b/plugins/talk-plugin-profile-data/package.json @@ -0,0 +1,12 @@ +{ + "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", + "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..f09b2dc7b --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/connect.js @@ -0,0 +1,14 @@ +const path = require('path'); + +module.exports = connectors => { + const { services: { Mailer } } = connectors; + + // Setup the mail templates. + ['txt', 'html'].forEach(format => { + Mailer.templates.register( + path.join(__dirname, 'emails', `download.${format}.ejs`), + 'download', + format + ); + }); +}; 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..6261ebe89 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/errors.js @@ -0,0 +1,18 @@ +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 } + ); + } +} + +module.exports = { ErrDownloadToken }; 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..09c9445ef --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -0,0 +1,106 @@ +const moment = require('moment'); +const uuid = require('uuid/v4'); +const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors'); +const { URL } = require('url'); + +// 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 7 days. + const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d'); + + // Check that the user has not already requested a download within the last + // 7 days. + const attempts = await downloadLinkLimiter.get(user.id); + if (attempts && attempts >= 1) { + throw new ErrMaxRateLimit(); + } + + // Check if the lastAccountDownload time is within 7 days. + if ( + user.lastAccountDownload && + moment(user.lastAccountDownload) + .add(7, '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 } } + ); +} + +// downloadUser will return the download file url that can be used to directly +// download the archive. +async function downloadUser(ctx, userID) { + const { downloadFileURL } = await generateDownloadLinks(ctx, userID); + return downloadFileURL; +} + +module.exports = ctx => ({ + User: { + requestDownloadLink: () => sendDownloadLink(ctx), + download: + // Only ADMIN users can execute an account download. + ctx.user && ctx.user.role === 'ADMIN' + ? userID => downloadUser(ctx, userID) + : () => Promise.reject(new ErrNotAuthorized()), + }, +}); 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..7a261772f --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -0,0 +1,23 @@ +const { get } = require('lodash'); + +module.exports = { + RootMutation: { + requestDownloadLink: async (_, args, { mutators: { User } }) => { + await User.requestDownloadLink(); + }, + 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); + }, + }, +}; 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..aa3d78adc --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -0,0 +1,33 @@ +type User { + + # lastAccountDownload is the date that the user last requested a comment + # download. + lastAccountDownload: Date +} + +type RequestDownloadLinkResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +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 + + # 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..d1059d470 --- /dev/null +++ b/plugins/talk-plugin-profile-data/translations.yml @@ -0,0 +1,18 @@ +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" + error: + DOWNLOAD_TOKEN_INVALID: "Your download link is not valid." diff --git a/plugins/talk-plugin-sort-most-liked/client/translations.yml b/plugins/talk-plugin-sort-most-liked/client/translations.yml index 8370ae922..00cb16f38 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 @@ en: label: Most liked first 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 462b4c2d4..f6ad5d118 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 @@ en: label: Most loved first 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 f8573d708..960652ebf 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 @@ en: label: Most replied first 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 f6634bd62..7e62f96f4 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 @@ en: label: Most liked first 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 219e8e9de..15dfdb932 100644 --- a/plugins/talk-plugin-sort-newest/client/translations.yml +++ b/plugins/talk-plugin-sort-newest/client/translations.yml @@ -12,7 +12,7 @@ en: label: Newest first 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 f764f4005..3c8f50f21 100644 --- a/plugins/talk-plugin-sort-oldest/client/translations.yml +++ b/plugins/talk-plugin-sort-oldest/client/translations.yml @@ -12,7 +12,7 @@ en: label: Oldest first 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-toxic-comments/server/__mocks__/perspective.js b/plugins/talk-plugin-toxic-comments/server/__mocks__/perspective.js new file mode 100644 index 000000000..cda3cf841 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/server/__mocks__/perspective.js @@ -0,0 +1,11 @@ +let values = {}; + +const getScores = () => values.getScores; + +const isToxic = () => values.isToxic; + +const setValues = newValues => { + values = newValues; +}; + +module.exports = { getScores, isToxic, setValues }; diff --git a/plugins/talk-plugin-toxic-comments/server/errors.js b/plugins/talk-plugin-toxic-comments/server/errors.js index 60135a8f8..a60bd549b 100644 --- a/plugins/talk-plugin-toxic-comments/server/errors.js +++ b/plugins/talk-plugin-toxic-comments/server/errors.js @@ -1,12 +1,16 @@ -const { APIError } = require('errors'); +const { TalkError } = require('errors'); // ErrToxic is sent during a `CreateComment` mutation where // `input.checkToxicity` is set to true and the comment contains // toxic language as determined by the perspective service. -const ErrToxic = new APIError('Comment is toxic', { - status: 400, - translation_key: 'COMMENT_IS_TOXIC', -}); +class ErrToxic extends TalkError { + constructor() { + super('Comment is toxic', { + status: 400, + translation_key: 'COMMENT_IS_TOXIC', + }); + } +} module.exports = { ErrToxic, diff --git a/plugins/talk-plugin-toxic-comments/server/hooks.js b/plugins/talk-plugin-toxic-comments/server/hooks.js index 7b9c93dad..d35b5cc20 100644 --- a/plugins/talk-plugin-toxic-comments/server/hooks.js +++ b/plugins/talk-plugin-toxic-comments/server/hooks.js @@ -1,11 +1,6 @@ const { getScores, isToxic } = require('./perspective'); const { ErrToxic } = require('./errors'); -// We don't add the hooks during _test_ as the perspective API is not available. -if (process.env.NODE_ENV === 'test') { - return null; -} - module.exports = { RootMutation: { createComment: { @@ -16,7 +11,7 @@ module.exports = { scores = await getScores(input.body); } catch (err) { // Warn and let mutation pass. - console.trace(err); + console.trace(err); // TODO: log/handle this differently? return; } @@ -27,7 +22,7 @@ module.exports = { if (isToxic(scores)) { if (input.checkToxicity) { - throw ErrToxic; + throw new ErrToxic(); } input.status = 'SYSTEM_WITHHELD'; diff --git a/plugins/talk-plugin-toxic-comments/server/hooks.spec.js b/plugins/talk-plugin-toxic-comments/server/hooks.spec.js new file mode 100644 index 000000000..d9fbe67e6 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/server/hooks.spec.js @@ -0,0 +1,31 @@ +const hooks = require('./hooks'); +const { ErrToxic } = require('./errors'); + +// Mock out the perspective api call. +jest.mock('./perspective'); + +describe('talk-plugin-toxic-comments', () => { + describe('hooks', () => { + beforeEach(() => { + require('./perspective').setValues({ isToxic: true }); + }); + + it('sets the correct values for a toxic comment', async () => { + let input = { body: 'This is a body.', checkToxicity: false }; + await hooks.RootMutation.createComment.pre(null, { input }, null, null); + expect(input).toHaveProperty('status', 'SYSTEM_WITHHELD'); + }); + + it('throws an error when a toxic comment is sent', async () => { + expect.assertions(1); + await expect( + hooks.RootMutation.createComment.pre( + null, + { input: { checkToxicity: true } }, + null, + null + ) + ).rejects.toBeInstanceOf(ErrToxic); + }); + }); +}); diff --git a/plugins/talk-plugin-viewing-options/client/translations.yml b/plugins/talk-plugin-viewing-options/client/translations.yml index 3b20e7b96..e066342a6 100644 --- a/plugins/talk-plugin-viewing-options/client/translations.yml +++ b/plugins/talk-plugin-viewing-options/client/translations.yml @@ -21,8 +21,8 @@ en: 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/api/v1/users.js b/routes/api/v1/users.js index e0de3b5db..481e5650c 100644 --- a/routes/api/v1/users.js +++ b/routes/api/v1/users.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const UsersService = require('../../../services/users'); -const errors = require('../../../errors'); +const { ErrMissingEmail, ErrNotFound } = require('../../../errors'); const authorization = require('../../../middleware/authorization'); const Limit = require('../../../services/limit'); @@ -40,17 +40,12 @@ router.post('/resend-verify', async (req, res, next) => { // Clean up and validate the email. email = email.toLowerCase().trim(); if (email.length < 5) { - return next(errors.ErrMissingEmail); + return next(new ErrMissingEmail()); } // Check if we're past the rate limit, if we are, stop now. Otherwise, record // this as an attempt to send a verification email. try { - const tries = await resendRateLimiter.get(email); - if (tries > 0) { - throw errors.ErrMaxRateLimit; - } - await resendRateLimiter.test(email); } catch (err) { return next(err); @@ -59,7 +54,7 @@ router.post('/resend-verify', async (req, res, next) => { try { const user = await UsersService.findLocalUser(email); if (!user) { - throw errors.ErrNotFound; + throw new ErrNotFound(); } await UsersService.sendEmailConfirmation(user, email, redirectUri); @@ -81,13 +76,13 @@ router.post( try { let user = await UsersService.findById(user_id); if (!user) { - return next(errors.ErrNotFound); + return next(new ErrNotFound()); } // Find the first local profile. const email = user.firstEmail; if (!email) { - return next(errors.ErrMissingEmail); + return next(new ErrMissingEmail()); } // Send the email to the first local profile that was found. 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 489a5e8b5..2dbfd51ff 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,8 +1,8 @@ const SetupService = require('../services/setup'); const authentication = require('../middleware/authentication'); +const logging = require('../middleware/logging'); const cookieParser = require('cookie-parser'); -const enabled = require('debug').enabled; -const errors = require('../errors'); +const { TalkError, ErrNotFound } = require('../errors'); const express = require('express'); const i18n = require('../middleware/i18n'); const path = require('path'); @@ -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(); @@ -149,19 +142,16 @@ router.use(require('./plugins')); // Catch 404 and forward to error handler. router.use((req, res, next) => { - next(errors.ErrNotFound); + next(new ErrNotFound()); }); +// Add logging for errors. +router.use(logging.error); + // General API error handler. Respond with the message and error if we have it // while returning a status code that makes sense. router.use('/api', (err, req, res, next) => { - if (err !== errors.ErrNotFound) { - if (process.env.NODE_ENV !== 'test' || enabled('talk:errors')) { - console.error(err); - } - } - - if (err instanceof errors.APIError) { + if (err instanceof TalkError) { res.status(err.status).json({ message: res.locals.t(`error.${err.translation_key}`), error: err, @@ -172,11 +162,7 @@ router.use('/api', (err, req, res, next) => { }); router.use('/', (err, req, res, next) => { - if (err !== errors.ErrNotFound) { - console.error(err); - } - - if (err instanceof errors.APIError) { + if (err instanceof TalkError) { res.status(err.status); res.render('error', { message: res.locals.t(`error.${err.translation_key}`), diff --git a/serve.js b/serve.js index ea06a6342..8b4d4556c 100644 --- a/serve.js +++ b/serve.js @@ -1,5 +1,5 @@ const app = require('./app'); -const errors = require('./errors'); +const { ErrSettingsInit, ErrInstallLock } = require('./errors'); const { createServer } = require('http'); const jobs = require('./jobs'); const MigrationService = require('./services/migration'); @@ -95,20 +95,16 @@ async function serve({ await SetupService.isAvailable(); logger.info('Setup is currently available, migrations not being checked'); - } catch (e) { + } catch (err) { // Check the error. - switch (e) { - case errors.ErrInstallLock: - case errors.ErrSettingsInit: - logger.info( - 'Setup is not currently available, migrations now being checked' - ); - - // The error was expected, just continue. - break; - default: - // The error was not expected, throw the error! - throw e; + if (err instanceof ErrInstallLock || err instanceof ErrSettingsInit) { + // The error was expected, just continue. + logger.info( + 'Setup is not currently available, migrations now being checked' + ); + } else { + // The error was not expected, throw the error! + throw err; } // Now try and check the migration status. diff --git a/services/assets.js b/services/assets.js index f229bb353..fbed22287 100644 --- a/services/assets.js +++ b/services/assets.js @@ -2,9 +2,12 @@ const CommentModel = require('../models/comment'); const AssetModel = require('../models/asset'); const SettingsService = require('./settings'); const DomainList = require('./domain_list'); -const errors = require('../errors'); -const merge = require('lodash/merge'); -const isEmpty = require('lodash/isEmpty'); +const { + ErrAssetURLAlreadyExists, + ErrNotFound, + ErrInvalidAssetURL, +} = require('../errors'); +const { merge, isEmpty } = require('lodash'); const { dotize } = require('./utils'); module.exports = class AssetsService { @@ -73,7 +76,7 @@ module.exports = class AssetsService { } if (!whitelisted) { - return Promise.reject(errors.ErrInvalidAssetURL); + throw new ErrInvalidAssetURL(url); } else { return AssetModel.findOneAndUpdate({ url }, update, { // Ensure that if it's new, we return the new object created. @@ -211,7 +214,7 @@ module.exports = class AssetsService { // Try to see if an asset already exists with the given url. let asset = await AssetsService.findByUrl(url); if (asset !== null) { - throw errors.ErrAssetURLAlreadyExists; + throw new ErrAssetURLAlreadyExists(); } // Seems that there was no other asset with the same url, try and perform @@ -227,7 +230,7 @@ module.exports = class AssetsService { dstAssetID, ]); if (!srcAsset || !dstAsset) { - throw errors.ErrNotFound; + throw new ErrNotFound(); } // Resolve the merge operation, this invloves moving all resources attached diff --git a/services/comments.js b/services/comments.js index a1f59c1e1..b9d73d91c 100644 --- a/services/comments.js +++ b/services/comments.js @@ -2,10 +2,13 @@ const CommentModel = require('../models/comment'); const { dotize } = require('./utils'); const debug = require('debug')('talk:services:comments'); const SettingsService = require('./settings'); - -const cloneDeep = require('lodash/cloneDeep'); -const errors = require('../errors'); -const merge = require('lodash/merge'); +const { merge, cloneDeep } = require('lodash'); +const { + ErrParentDoesNotVisible, + ErrNotFound, + ErrNotAuthorized, + ErrEditWindowHasEnded, +} = require('../errors'); const incrReplyCount = async (comment, value) => { try { @@ -40,7 +43,7 @@ module.exports = { if (parent_id !== null) { const parent = await CommentModel.findOne({ id: parent_id }); if (parent === null || !parent.visible) { - throw errors.ErrParentDoesNotVisible; + throw new ErrParentDoesNotVisible(); } } @@ -126,7 +129,7 @@ module.exports = { const comment = await CommentModel.findOne({ id }); if (comment == null) { debug('rejecting comment edit because comment was not found'); - throw errors.ErrNotFound; + throw new ErrNotFound(); } // Check to see if the user was't allowed to edit it. @@ -134,7 +137,7 @@ module.exports = { debug( 'rejecting comment edit because author id does not match editing user' ); - throw errors.ErrNotAuthorized; + throw new ErrNotAuthorized(); } // Check to see if the comment had a status that was editable. @@ -142,13 +145,13 @@ module.exports = { debug( 'rejecting comment edit because original comment has a non-editable status' ); - throw errors.ErrNotAuthorized; + throw new ErrNotAuthorized(); } // Check to see if the edit window expired. if (comment.created_at <= lastEditableCommentCreatedAt) { debug('rejecting comment edit because outside edit time window'); - throw errors.ErrEditWindowHasEnded; + throw new ErrEditWindowHasEnded(); } throw new Error('comment edit failed for an unexpected reason'); @@ -198,7 +201,7 @@ module.exports = { ); if (originalComment == null) { - throw errors.ErrNotFound; + throw new ErrNotFound(); } const editedComment = new CommentModel(originalComment.toObject()); diff --git a/services/limit.js b/services/limit.js index 6d46f3715..573d7a815 100644 --- a/services/limit.js +++ b/services/limit.js @@ -1,5 +1,5 @@ const ms = require('ms'); -const errors = require('../errors'); +const { ErrMaxRateLimit } = require('../errors'); const { createClientFactory } = require('./redis'); const client = createClientFactory(); @@ -60,7 +60,7 @@ class Limit { } if (tries > this.max) { - throw errors.ErrMaxRateLimit; + throw new ErrMaxRateLimit(this.max, tries); } return tries; diff --git a/services/logging.js b/services/logging.js index 47ef93af8..850283f40 100644 --- a/services/logging.js +++ b/services/logging.js @@ -1,18 +1,45 @@ const { version } = require('../package.json'); -const Logger = require('bunyan'); +const path = require('path'); +const { createLogger: createBunyanLogger, stdSerializers } = require('bunyan'); const { LOGGING_LEVEL, REVISION_HASH } = require('../config'); -const logger = new Logger({ +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') { + return [ + { + level: LOGGING_LEVEL, + type: 'raw', + stream: debug({ + basepath: path.resolve(__dirname, '..'), + forceColor: true, + }), + }, + ]; + } + + // In production, emit JSON. + return [{ stream: process.stdout, level: LOGGING_LEVEL }]; +})(); + +// logger is the base logger used by all logging systems in Talk. +const logger = createBunyanLogger({ src: true, name: 'talk', version, revision: REVISION_HASH, - level: LOGGING_LEVEL, - serializers: Logger.stdSerializers, + streams, + serializers: stdSerializers, }); -// Create the logging instance that all logger's are branched from. -function createLogger(name, traceID) { - return logger.child({ origin: name, traceID }); -} +/** + * + * @param {String} origin the origin name used by the logger + * @param {String} traceID the id of the request being made + */ +const createLogger = (origin, traceID) => logger.child({ origin, traceID }); module.exports = { logger, createLogger }; 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/moderation/index.js b/services/moderation/index.js index 4d87e190f..530d6f2a6 100644 --- a/services/moderation/index.js +++ b/services/moderation/index.js @@ -1,4 +1,4 @@ -const errors = require('../../errors'); +const { ErrNotFound } = require('../../errors'); const get = require('lodash/get'); // Load in the phases to use. @@ -92,14 +92,14 @@ const fetchOptions = async (ctx, comment) => { const assetID = get(comment, 'asset_id', null); if (assetID === null) { // And leave now if this asset wasn't found. - throw errors.ErrNotFound; + throw new ErrNotFound(); } // Load the asset. const asset = await Assets.getByID.load(assetID); if (!asset) { // And leave now if this asset wasn't found. - throw errors.ErrNotFound; + throw new ErrNotFound(); } // Combine the asset and the settings to get the asset settings. diff --git a/services/moderation/phases/commentLength.js b/services/moderation/phases/commentLength.js index 925115326..e19198ef1 100644 --- a/services/moderation/phases/commentLength.js +++ b/services/moderation/phases/commentLength.js @@ -8,7 +8,7 @@ module.exports = ( ) => { // Check to see if the body is too short, if it is, then complain about it! if (comment.body.length < 2) { - throw ErrCommentTooShort; + throw new ErrCommentTooShort(comment.body.length); } // Reject if the comment is too long diff --git a/services/mongoose.js b/services/mongoose.js index 20765293a..167b0d45b 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -1,50 +1,47 @@ -const { MONGO_URL, WEBPACK, CREATE_MONGO_INDEXES } = require('../config'); - +const { + MONGO_URL, + WEBPACK, + CREATE_MONGO_INDEXES, + LOGGING_LEVEL, +} = require('../config'); +const { logger } = require('./logging'); const mongoose = require('mongoose'); -const debug = require('debug')('talk:db'); -const enabled = require('debug').enabled; -const queryDebugger = require('debug')('talk:db:query'); - -// Loading the formatter from Mongoose: -// -// https://github.com/Automattic/mongoose/blob/1a93d1f4d12e441e17ddf451e96fbc5f6e8f54b8/lib/drivers/node-mongodb-native/collection.js#L182 -// -// so we can wrap parameters. -const formatter = require('mongoose').Collection.prototype.$format; // Provide a newly wrapped debugQuery function which wraps the `debug` package. -function debugQuery(name, i, ...args) { - let functionCall = ['db', name, i].join('.'); - let _args = []; - for (let j = args.length - 1; j >= 0; --j) { - if (formatter(args[j]) || _args.length) { - _args.unshift(formatter(args[j])); - } - } - - let params = `(${_args.join(', ')})`; - - queryDebugger(functionCall + params); +function debugQuery(name, operation, ...args) { + logger.debug( + { + query: `db.${name}.${operation}(${args + .map(arg => JSON.stringify(arg)) + .join(', ')})`, + }, + 'mongodb query' + ); } // Use native promises mongoose.Promise = global.Promise; -// Check if debugging is enabled on the talk:db prefix. -if (enabled('talk:db:query')) { +// Check if verbose logging is enabled. +if (['debug', 'trace'].includes(LOGGING_LEVEL)) { // Enable the mongoose debugger, here we wrap the similar print function // provided by setting the debug parameter. mongoose.set('debug', debugQuery); } if (WEBPACK) { - debug('Not connecting to mongodb during webpack build'); + logger.debug('Not connecting to mongodb during webpack build'); - // @wyattjoh: We didn't call connect, but because we include mongoose, it will hold the socket ready, - // preventing node from exiting. Calling disconnect here just ensures that the application - // can quit correctly. + // @wyattjoh: We didn't call connect, but because we include mongoose, it will + // hold the socket ready, preventing node from exiting. Calling disconnect + // here just ensures that the application can quit correctly. mongoose.disconnect(); } else { + mongoose.connection.on('connected', () => logger.debug('mongodb connected')); + mongoose.connection.on('disconnected', () => + logger.debug('mongodb disconnected') + ); + // Connect to the Mongo instance. mongoose .connect(MONGO_URL, { @@ -53,9 +50,6 @@ if (WEBPACK) { autoIndex: CREATE_MONGO_INDEXES, }, }) - .then(() => { - debug('connection established'); - }) .catch(err => { console.error(err); process.exit(1); @@ -66,10 +60,14 @@ module.exports = mongoose; // Here we include all the models that mongoose is used for, this ensures that // when we import mongoose that we also start up all the indexing operations -// here. -require('../models/action'); -require('../models/asset'); -require('../models/comment'); -require('../models/setting'); -require('../models/user'); -require('./migration'); +// here. No point also in importing this if we're not actually doing any +// indexing now. +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/passport.js b/services/passport.js index 5748c911b..0ae06afb8 100644 --- a/services/passport.js +++ b/services/passport.js @@ -6,7 +6,12 @@ const TokensService = require('./tokens'); const fetch = require('node-fetch'); const FormData = require('form-data'); const LocalStrategy = require('passport-local').Strategy; -const errors = require('../errors'); +const { + ErrLoginAttemptMaximumExceeded, + ErrNotAuthorized, + ErrAuthentication, + ErrNotVerified, +} = require('../errors'); const uuid = require('uuid'); const debug = require('debug')('talk:services:passport'); const bowser = require('bowser'); @@ -75,7 +80,7 @@ const HandleGenerateCredentials = (req, res, next) => (err, user) => { } if (!user) { - return next(errors.ErrNotAuthorized); + return next(new ErrNotAuthorized()); } // Generate the token to re-issue to the frontend. @@ -117,7 +122,7 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => { if (!user) { return res.render('auth-callback', { - auth: { err: errors.ErrNotAuthorized, data: null }, + auth: { err: new ErrNotAuthorized(), data: null }, }); } @@ -143,7 +148,7 @@ async function ValidateUserLogin(loginProfile, user, done) { } if (user.disabled) { - return done(new errors.ErrAuthentication('Account disabled')); + return done(new ErrAuthentication('Account disabled')); } // If the user isn't a local user (i.e., a social user). @@ -169,7 +174,7 @@ async function ValidateUserLogin(loginProfile, user, done) { // If the profile doesn't have a metadata field, or it does not have a // confirmed_at field, or that field is null, then send them back. if (_.get(profile, 'metadata.confirmed_at', null) === null) { - return done(errors.ErrNotVerified); + return done(new ErrNotVerified()); } } @@ -209,7 +214,7 @@ const checkGeneralTokenBlacklist = jwt => .get(`jtir[${jwt.jti}]`) .then(expiry => { if (expiry != null) { - throw new errors.ErrAuthentication('token was revoked'); + throw new ErrAuthentication('token was revoked'); } }); @@ -392,7 +397,7 @@ const HandleFailedAttempt = async (email, userNeedsRecaptcha) => { await UsersService.recordLoginAttempt(email); } catch (err) { if ( - err === errors.ErrLoginAttemptMaximumExceeded && + err instanceof ErrLoginAttemptMaximumExceeded && !userNeedsRecaptcha && RECAPTCHA_ENABLED ) { @@ -448,7 +453,7 @@ passport.use( try { await UsersService.checkLoginAttempts(email); } catch (err) { - if (err === errors.ErrLoginAttemptMaximumExceeded) { + if (err instanceof ErrLoginAttemptMaximumExceeded) { // This says, we didn't have a recaptcha, yet we needed one.. Reject // here. diff --git a/services/redis.js b/services/redis.js index 2576d2c13..0a25b34d6 100644 --- a/services/redis.js +++ b/services/redis.js @@ -1,7 +1,5 @@ const Redis = require('ioredis'); const merge = require('lodash/merge'); -const debug = require('debug')('talk:services:redis'); -const enabled = require('debug').enabled('talk:services:redis'); const { REDIS_URL, REDIS_RECONNECTION_BACKOFF_FACTOR, @@ -9,29 +7,32 @@ const { REDIS_CLIENT_CONFIG, REDIS_CLUSTER_MODE, REDIS_CLUSTER_CONFIGURATION, + LOGGING_LEVEL, } = require('../config'); +const { createLogger } = require('./logging'); +const logger = createLogger('redis'); const attachMonitors = client => { - debug('client created'); + logger.debug('client created'); // Debug events. - if (enabled) { - client.on('connect', () => debug('client connected')); - client.on('ready', () => debug('client ready')); - client.on('close', () => debug('client closed the connection')); + if (['debug', 'trace'].includes(LOGGING_LEVEL)) { + client.on('connect', () => logger.info('client connected')); + client.on('ready', () => logger.debug('client ready')); + client.on('close', () => logger.debug('client closed the connection')); client.on('reconnecting', () => - debug('client connection lost, attempting to reconnect') + logger.debug('client connection lost, attempting to reconnect') ); - client.on('end', () => debug('client ended')); + client.on('end', () => logger.debug('client ended')); } // Error events. client.on('error', err => { if (err) { - console.error('Error connecting to redis:', err); + logger.error({ err }, 'cannot connect to redis'); } }); - client.on('node error', err => debug('node error', err)); + client.on('node error', err => logger.error({ err }, 'node error')); }; function retryStrategy(times) { @@ -40,7 +41,7 @@ function retryStrategy(times) { REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME ); - debug(`retry strategy: try to reconnect ${delay} ms from now`); + logger.debug(`retry strategy: try to reconnect ${delay} ms from now`); return delay; } diff --git a/services/settings.js b/services/settings.js index f918f9d01..ee5125183 100644 --- a/services/settings.js +++ b/services/settings.js @@ -1,6 +1,6 @@ const SettingModel = require('../models/setting'); const cache = require('./cache'); -const errors = require('../errors'); +const { ErrSettingsNotInit } = require('../errors'); const { dotize } = require('./utils'); const { SETTINGS_CACHE_TIME } = require('../config'); @@ -17,7 +17,7 @@ const retrieve = async fields => { settings = await SettingModel.findOne(selector); } if (!settings) { - throw errors.ErrSettingsNotInit; + throw new ErrSettingsNotInit(); } return settings; diff --git a/services/setup.js b/services/setup.js index 84f915ff2..2517d376b 100644 --- a/services/setup.js +++ b/services/setup.js @@ -2,7 +2,12 @@ const UsersService = require('./users'); const SettingsService = require('./settings'); const MigrationService = require('./migration'); const SettingsModel = require('../models/setting'); -const errors = require('../errors'); +const { + ErrMissingEmail, + ErrInstallLock, + ErrSettingsInit, + ErrSettingsNotInit, +} = require('../errors'); const { INSTALL_LOCK } = require('../config'); /** @@ -16,25 +21,25 @@ module.exports = class SetupService { static async isAvailable() { // Check if we have an install lock present. if (INSTALL_LOCK) { - throw errors.ErrInstallLock; + throw new ErrInstallLock(); } try { - // Get the current settings, we are expecing an error here. + // Get the current settings, we are expecting an error here. await SettingsService.retrieve(); // We should NOT have gotten a settings object, this means that the // application is already setup. Error out here. - throw errors.ErrSettingsInit; - } catch (e) { - // If the error is `not init`, then we're good, otherwise, it's something - // else. - if (e !== errors.ErrSettingsNotInit) { - throw e; + throw new ErrSettingsInit(); + } catch (err) { + // Allow the request to keep going here. + if (err instanceof ErrSettingsNotInit) { + return; } - // Allow the request to keep going here. - return; + // If the error is `not init`, then we're good, otherwise, it's something + // else. + throw err; } } @@ -44,7 +49,7 @@ module.exports = class SetupService { static validate({ settings, user: { email, username, password } }) { // Verify the email address of the user. if (!email) { - return Promise.reject(errors.ErrMissingEmail); + throw new ErrMissingEmail(); } // Create a settings model to use for validation. diff --git a/services/tags.js b/services/tags.js index 8183d0165..cc8934e0a 100644 --- a/services/tags.js +++ b/services/tags.js @@ -1,12 +1,10 @@ const CommentModel = require('../models/comment'); const AssetModel = require('../models/asset'); const UserModel = require('../models/user'); - const AssetsService = require('./assets'); const SettingsService = require('./settings'); const { ADD_COMMENT_TAG } = require('../perms/constants'); - -const errors = require('../errors'); +const { ErrNotAuthorized } = require('../errors'); const updateModel = async (item_type, query, update) => { // Get the model to update with. @@ -120,13 +118,13 @@ class TagsService { return { tagLink, ownership: true }; } - throw errors.ErrNotAuthorized; + throw new ErrNotAuthorized(); } // Only admin/moderators can modify unique tags, these are tags that are not // in the global list. if (!user.can(ADD_COMMENT_TAG)) { - throw errors.ErrNotAuthorized; + throw new ErrNotAuthorized(); } // Generate the tag in the event now that we have to create the tag for this diff --git a/services/users.js b/services/users.js index 0617d5158..e53df4119 100644 --- a/services/users.js +++ b/services/users.js @@ -1,18 +1,35 @@ const uuid = require('uuid'); +const moment = require('moment'); const bcrypt = require('bcryptjs'); -const errors = require('../errors'); +const { + ErrMaxRateLimit, + ErrLoginAttemptMaximumExceeded, + ErrNotFound, + ErrPermissionUpdateUsername, + ErrSameUsernameProvided, + ErrUsernameTaken, + ErrMissingUsername, + ErrSpecialChars, + ErrMissingPassword, + ErrPasswordTooShort, + ErrMissingEmail, + ErrEmailTaken, + ErrEmailAlreadyVerified, + ErrCannotIgnoreStaff, +} = require('../errors'); const { difference, sample, some, merge, random } = require('lodash'); const { ROOT_URL } = require('../config'); const { jwt: JWT_SECRET } = require('../secrets'); const debug = require('debug')('talk:services:users'); -const UserModel = require('../models/user'); +const User = 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 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 EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; @@ -22,21 +39,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(), @@ -59,8 +75,8 @@ class UsersService { try { await loginRateLimiter.test(email.toLowerCase().trim()); } catch (err) { - if (err === errors.ErrMaxRateLimit) { - throw errors.ErrLoginAttemptMaximumExceeded; + if (err instanceof ErrMaxRateLimit) { + throw new ErrLoginAttemptMaximumExceeded(); } throw err; @@ -68,7 +84,7 @@ class UsersService { } static async setSuspensionStatus(id, until, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id }, { $set: { @@ -89,9 +105,9 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { - throw errors.ErrNotFound; + throw new ErrNotFound(); } // Date comparisons are difficult when using MongoDB. Javascript will @@ -112,12 +128,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 }); } @@ -125,7 +141,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': { @@ -150,10 +166,10 @@ class UsersService { runValidators: true, } ); - if (user === null) { - user = await UserModel.findOne({ id }); - if (user === null) { - throw errors.ErrNotFound; + if (!user) { + user = await User.findOne({ id }); + if (!user) { + throw new ErrNotFound(); } if (user.status.banned.status === status) { @@ -165,7 +181,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, @@ -178,7 +194,7 @@ class UsersService { } static async setUsernameStatus(id, status, assignedBy = null) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id, 'status.username.status': { @@ -202,9 +218,9 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { - throw errors.ErrNotFound; + throw new ErrNotFound(); } if (user.status.username.status === status) { @@ -219,55 +235,72 @@ 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 errors.ErrNotFound; + throw new ErrNotFound(); } - if (user.status.username.status !== fromStatus) { - throw errors.ErrPermissionUpdateUsername; - } - - if (!resetAllowed && user.username === username) { - throw errors.ErrSameUsernameProvided; + if ( + !['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) || + user.status.username.history.some(({ created_at }) => + moment(created_at).isAfter(oldestEditTime) + ) + ) { + throw new ErrPermissionUpdateUsername(); } throw new Error('edit username failed for an unexpected reason'); @@ -276,32 +309,64 @@ class UsersService { return user; } catch (err) { if (err.code === 11000) { - throw errors.ErrUsernameTaken; + throw new ErrUsernameTaken(); } throw err; } } - 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; + } } /** @@ -317,7 +382,7 @@ class UsersService { } if (attempts >= RECAPTCHA_INCORRECT_TRIGGER) { - throw errors.ErrLoginAttemptMaximumExceeded; + throw new ErrLoginAttemptMaximumExceeded(); } } @@ -325,7 +390,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: { @@ -357,11 +422,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) { @@ -381,7 +446,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 }, }, @@ -417,7 +482,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, @@ -432,10 +497,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 }], @@ -465,11 +530,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', @@ -492,9 +553,13 @@ class UsersService { } static async changePassword(id, password) { + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); - return UserModel.update( + return User.update( { id }, { $inc: { __v: 1 }, @@ -515,11 +580,11 @@ class UsersService { const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/; if (!username) { - throw errors.ErrMissingUsername; + throw new ErrMissingUsername(); } if (!onlyLettersNumbersUnderscore.test(username)) { - throw errors.ErrSpecialChars; + throw new ErrSpecialChars(); } if (checkAgainstWordlist) { @@ -539,11 +604,11 @@ class UsersService { */ static isValidPassword(password) { if (!password) { - throw errors.ErrMissingPassword; + throw new ErrMissingPassword(); } if (password.length < 8) { - throw errors.ErrPasswordTooShort; + throw new ErrPasswordTooShort(); } return password; @@ -558,20 +623,20 @@ class UsersService { */ static async createLocalUser(ctx, email, password, username) { if (!email) { - throw errors.ErrMissingEmail; + throw new ErrMissingEmail(); } email = email.toLowerCase().trim(); 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); - let user = new UserModel({ + let user = new User({ username, lowercaseUsername: username.toLowerCase(), password: hashedPassword, @@ -596,9 +661,9 @@ class UsersService { } catch (err) { if (err.code === 11000) { if (err.message.match('Username')) { - throw errors.ErrUsernameTaken; + throw new ErrUsernameTaken(); } - throw errors.ErrEmailTaken; + throw new ErrEmailTaken(); } throw err; } @@ -615,11 +680,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 }); } /** @@ -627,7 +688,7 @@ class UsersService { * @param {String} id user id (uuid) */ static findById(id) { - return UserModel.findOne({ id }); + return User.findOne({ id }); } /** @@ -637,7 +698,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) { @@ -654,7 +715,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findByIdArray(ids) { - return UserModel.find({ + return User.find({ id: { $in: ids }, }); } @@ -664,7 +725,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findPublicByIdArray(ids) { - return UserModel.find( + return User.find( { id: { $in: ids }, }, @@ -678,15 +739,13 @@ class UsersService { */ static async createPasswordResetToken(email, loc) { if (!email || typeof email !== 'string') { - throw new Error( - 'email is required when creating a JWT for resetting passord' - ); + throw new ErrMissingEmail(); } 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) { @@ -733,28 +792,49 @@ 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 }; + } + + // 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 bcrypt.hash(password, SALT_ROUNDS); + + // Update the user's password. + await User.update( + { id: user.id, __v: version }, + { + $inc: { __v: 1 }, + $set: { + password: hashedPassword, + }, + } + ); + + return { user, redirect }; } /** @@ -762,7 +842,7 @@ class UsersService { * @return {Promise} */ static count(query = {}) { - return UserModel.count(query); + return User.count(query); } /** @@ -770,7 +850,7 @@ class UsersService { * @return {Promise} */ static all() { - return UserModel.find(); + return User.find(); } /** @@ -778,7 +858,7 @@ class UsersService { * @return {Promise} */ static updateSettings(id, settings) { - return UserModel.update( + return User.update( { id, }, @@ -798,7 +878,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, @@ -837,7 +917,7 @@ class UsersService { // Ensure that the user email hasn't already been verified. if (profile && profile.metadata && profile.metadata.confirmed_at) { - throw errors.ErrEmailAlreadyVerified; + throw new ErrEmailAlreadyVerified(); } return JWT_SECRET.sign( @@ -861,11 +941,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: { @@ -875,16 +955,16 @@ class UsersService { }, }); if (!user) { - throw errors.ErrNotFound; + throw new ErrNotFound(); } const profile = user.profiles.find(({ id }) => id === decoded.email); if (!profile) { - throw errors.ErrNotFound; + throw new ErrNotFound(); } if (profile.metadata && profile.metadata.confirmed_at !== null) { - throw errors.ErrEmailAlreadyVerified; + throw new ErrEmailAlreadyVerified(); } return decoded; @@ -898,13 +978,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 }; } @@ -913,7 +991,7 @@ class UsersService { * Marks the email on the user as confirmed. */ static confirmEmail(id, email) { - return UserModel.update( + return User.update( { id, profiles: { @@ -941,12 +1019,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 errors.ErrCannotIgnoreStaff; + throw new ErrCannotIgnoreStaff(); } - return UserModel.update( + return User.update( { id }, { $addToSet: { @@ -964,7 +1042,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: { @@ -975,7 +1053,7 @@ class UsersService { } } -module.exports = UsersService; +module.exports = Users; // Extract all the tokenUserNotFound plugins so we can integrate with other // providers. diff --git a/services/wordlist.js b/services/wordlist.js index 04012adcb..9e7e1581e 100644 --- a/services/wordlist.js +++ b/services/wordlist.js @@ -1,7 +1,7 @@ const debug = require('debug')('talk:services:wordlist'); const _ = require('lodash'); const SettingsService = require('./settings'); -const Errors = require('../errors'); +const { ErrContainsProfanity } = require('../errors'); const memoize = require('lodash/memoize'); const { escapeRegExp } = require('./regex'); @@ -96,7 +96,7 @@ class Wordlist { `the field "${fieldName}" contained a phrase "${phrase}" which contained a banned word/phrase` ); - errors.banned = Errors.ErrContainsProfanity; + errors.banned = new ErrContainsProfanity(phrase); // Stop looping through the fields now, we discovered the worst possible // situation (a banned word). @@ -109,7 +109,7 @@ class Wordlist { `the field "${fieldName}" contained a phrase "${phrase}" which contained a suspected word/phrase` ); - errors.suspect = Errors.ErrContainsProfanity; + errors.suspect = new ErrContainsProfanity(phrase); // Continue looping through the fields now, we discovered a possible bad // word (suspect). @@ -167,7 +167,7 @@ class Wordlist { return wl.load().then(() => { if (wl.regexp.banned.test(username)) { - return Errors.ErrContainsProfanity; + throw new ErrContainsProfanity(username); } }); } 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/admin.js b/test/e2e/page_objects/admin.js index 218afe425..8e319e499 100644 --- a/test/e2e/page_objects/admin.js +++ b/test/e2e/page_objects/admin.js @@ -128,8 +128,8 @@ module.exports = { rejectedTab: '.talk-admin-user-detail-rejected-tab', historyTab: '.talk-admin-user-detail-history-tab', historyPane: '.talk-admin-user-detail-history-tab-pane', - accountHistory: '.talk-admin-account-history', - accountHistoryRowStatus: '.talk-admin-account-history-row-status', + UserHistory: '.talk-admin-user-history', + UserHistoryRowStatus: '.talk-admin-user-history-row-status', actionsMenu: '.talk-admin-user-detail-actions-button', actionItemSuspendUser: '.action-menu-item#suspendUser', actionMenuButton: 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/e2e/specs/03_embedStream.js b/test/e2e/specs/03_embedStream.js index f45c467e8..4b6831271 100644 --- a/test/e2e/specs/03_embedStream.js +++ b/test/e2e/specs/03_embedStream.js @@ -96,12 +96,6 @@ module.exports = { comments.logout(); }, - 'not logged in user clicks my profile tab': client => { - const embedStream = client.page.embedStream(); - const profile = embedStream.goToProfileSection(); - - profile.assert.visible('@notLoggedIn'); - }, 'admin logs in': client => { const { testData: { admin } } = client.globals; const embedStream = client.page.embedStream(); diff --git a/test/e2e/specs/06_suspendUser.js b/test/e2e/specs/06_suspendUser.js index 0b89c5c01..81fde3781 100644 --- a/test/e2e/specs/06_suspendUser.js +++ b/test/e2e/specs/06_suspendUser.js @@ -125,7 +125,7 @@ module.exports = { .waitForElementVisible('@historyTab') .click('@historyTab') .waitForElementVisible('@historyPane') - .waitForElementVisible('@accountHistory') + .waitForElementVisible('@UserHistory') .click('@closeButton'); }, 'admin logs out': client => { diff --git a/test/server/graph/context.js b/test/server/graph/context.js index a788f509b..b3319d5c4 100644 --- a/test/server/graph/context.js +++ b/test/server/graph/context.js @@ -1,6 +1,6 @@ const User = require('../../../models/user'); const Context = require('../../../graph/context'); -const errors = require('../../../errors'); +const { ErrNotAuthorized } = require('../../../errors'); const SettingsService = require('../../../services/settings'); const { expect } = require('chai'); @@ -54,7 +54,7 @@ describe('graph.Context', () => { throw new Error('should not reach this point'); }) .catch(err => { - expect(err).to.be.equal(errors.ErrNotAuthorized); + expect(err).to.be.an.instanceof(ErrNotAuthorized); }); }); }); 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/test/server/services/wordlist.js b/test/server/services/wordlist.js index 7a11dbf80..313dde49f 100644 --- a/test/server/services/wordlist.js +++ b/test/server/services/wordlist.js @@ -1,4 +1,4 @@ -const Errors = require('../../../errors'); +const { ErrContainsProfanity } = require('../../../errors'); const Wordlist = require('../../../services/wordlist'); const SettingsService = require('../../../services/settings'); @@ -103,7 +103,8 @@ describe('services.Wordlist', () => { 'content' ); - expect(errors).to.have.property('banned', Errors.ErrContainsProfanity); + expect(errors).to.have.property('banned'); + expect(errors.banned).to.be.an.instanceof(ErrContainsProfanity); }); it('does not match on bodies not containing bad words', () => { diff --git a/test/setupJest.js b/test/setupJest.js new file mode 100644 index 000000000..6ed78a9c5 --- /dev/null +++ b/test/setupJest.js @@ -0,0 +1,31 @@ +const mongoose = require('../services/mongoose'); + +beforeAll(function(done) { + mongoose.connection.on('open', function(err) { + if (err) { + return done(err); + } + + return done(); + }); +}, 30000); + +beforeEach(async () => { + await Promise.all( + Object.keys(mongoose.connection.collections).map(collection => { + return new Promise((resolve, reject) => { + mongoose.connection.collections[collection].remove(function(err) { + if (err) { + return reject(err); + } + + return resolve(); + }); + }); + }) + ); +}); + +afterAll(async function() { + await mongoose.disconnect(); +}); 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') %>

+
+
+ +
+