diff --git a/.gitignore b/.gitignore index 1235e0db0..e8cb92653 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ plugins/* !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/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index 28255aeb5..360ceaa93 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -10,6 +10,7 @@ import Configure from 'routes/Configure'; import StreamSettings from './routes/Configure/containers/StreamSettings'; import ModerationSettings from './routes/Configure/containers/ModerationSettings'; import TechSettings from './routes/Configure/containers/TechSettings'; +import OrganizationSettings from './routes/Configure/containers/OrganizationSettings'; import { ModerationLayout, Moderation } from 'routes/Moderation'; @@ -25,6 +26,7 @@ const routes = ( + diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 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/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..38f211781 --- /dev/null +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js @@ -0,0 +1,187 @@ +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..7f0951154 100644 --- a/client/coral-admin/src/routes/Configure/containers/Configure.js +++ b/client/coral-admin/src/routes/Configure/containers/Configure.js @@ -16,6 +16,7 @@ import { hideSaveDialog, } from '../../../actions/configure'; import Configure from '../components/Configure'; +import OrganizationSettings from './OrganizationSettings'; import { withRouter } from 'react-router'; class ConfigureContainer extends React.Component { @@ -83,18 +84,21 @@ class ConfigureContainer extends React.Component { return ; } + const activeSection = this.props.routes[3].path; + return ( {this.props.children} @@ -110,10 +114,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 +127,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} /> + + + + ) : ( + + )} + + ); + } +} + +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/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..3909d5dce 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(); diff --git a/routes/api/v1/graph.js b/routes/api/v1/graph.js index 7d13d4c92..40e87415b 100644 --- a/routes/api/v1/graph.js +++ b/routes/api/v1/graph.js @@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions)); if (process.env.NODE_ENV !== 'production') { // Interactive graphiql interface. router.use('/iql', staticTemplate, (req, res) => { - res.render('graphiql', { + res.render('api/graphiql', { endpointURL: 'api/v1/graph/ql', }); }); diff --git a/routes/assets/index.js b/routes/dev/assets.js similarity index 93% rename from routes/assets/index.js rename to routes/dev/assets.js index 35fb6e9ed..cb1f27e43 100644 --- a/routes/assets/index.js +++ b/routes/dev/assets.js @@ -14,7 +14,7 @@ router.get('/id/:asset_id', async (req, res, next) => { return next(errors.ErrNotFound); } - res.render('article', { + res.render('dev/article', { title: asset.title, asset_id: asset.id, asset_url: asset.url, @@ -27,7 +27,7 @@ router.get('/id/:asset_id', async (req, res, next) => { }); router.get('/title/:asset_title', (req, res) => { - return res.render('article', { + return res.render('dev/article', { title: req.params.asset_title.split('-').join(' '), asset_url: '', asset_id: null, @@ -42,7 +42,7 @@ router.get('/', async (req, res, next) => { try { const assets = await Assets.all(skip, limit); - res.render('articles', { + res.render('dev/articles', { assets: assets, }); } catch (err) { diff --git a/routes/dev/index.js b/routes/dev/index.js new file mode 100644 index 000000000..ac72db254 --- /dev/null +++ b/routes/dev/index.js @@ -0,0 +1,25 @@ +const express = require('express'); +const url = require('url'); +const router = express.Router(); + +const { MOUNT_PATH } = require('../../url'); +const SetupService = require('../../services/setup'); +const staticTemplate = require('../../middleware/staticTemplate'); + +router.use('/assets', staticTemplate, require('./assets')); +router.get('/', staticTemplate, async (req, res) => { + try { + await SetupService.isAvailable(); + return res.redirect(url.resolve(MOUNT_PATH, 'admin/install')); + } catch (e) { + return res.render('dev/article', { + title: 'Coral Talk', + asset_url: '', + asset_id: '', + body: '', + basePath: '/static/embed/stream', + }); + } +}); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js index b6e46791e..2dbfd51ff 100644 --- a/routes/index.js +++ b/routes/index.js @@ -75,6 +75,7 @@ router.use(compression()); //============================================================================== router.use('/admin', staticTemplate, require('./admin')); +router.use('/account', staticTemplate, require('./account')); router.use('/login', staticTemplate, require('./login')); router.use('/embed', staticTemplate, require('./embed')); @@ -114,22 +115,14 @@ router.use('/api', require('./api')); //============================================================================== if (process.env.NODE_ENV !== 'production') { - router.use('/assets', staticTemplate, require('./assets')); - router.get('/', staticTemplate, async (req, res) => { - try { - await SetupService.isAvailable(); - return res.redirect('/admin/install'); - } catch (e) { - return res.render('article', { - title: 'Coral Talk', - asset_url: '', - asset_id: '', - body: '', - basePath: '/static/embed/stream', - }); - } + // In development, mount the /dev routes, as well as redirect the root url to + // the development route. + router.use('/dev', require('./dev')); + router.get('/', (req, res) => { + res.redirect(url.resolve(MOUNT_PATH, 'dev'), 302); }); } else { + // In production, optionally redirect to the install if not ran, or the admin. router.get('/', async (req, res, next) => { try { await SetupService.isAvailable(); diff --git a/services/logging.js b/services/logging.js index 7ae3654b1..850283f40 100644 --- a/services/logging.js +++ b/services/logging.js @@ -2,13 +2,13 @@ const { version } = require('../package.json'); const path = require('path'); const { createLogger: createBunyanLogger, stdSerializers } = require('bunyan'); const { LOGGING_LEVEL, REVISION_HASH } = require('../config'); +const debug = require('bunyan-debug-stream'); // Streams enables the ability for development logs to be readable to a human, // but will send JSON logs in production that's parsable by a system like ELK. const streams = (() => { // In development, use the debug stream printer. if (process.env.NODE_ENV !== 'production') { - const debug = require('bunyan-debug-stream'); return [ { level: LOGGING_LEVEL, diff --git a/services/mailer/templates/email-confirm.html.ejs b/services/mailer/templates/email-confirm.html.ejs index 76a8ea47b..396386e21 100644 --- a/services/mailer/templates/email-confirm.html.ejs +++ b/services/mailer/templates/email-confirm.html.ejs @@ -1,3 +1,3 @@

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

-

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

+

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

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

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

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

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

diff --git a/services/mailer/templates/password-reset.txt.ejs b/services/mailer/templates/password-reset.txt.ejs index e8db4bab2..2b5e60a9b 100644 --- a/services/mailer/templates/password-reset.txt.ejs +++ b/services/mailer/templates/password-reset.txt.ejs @@ -1,3 +1,3 @@ <%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>: -<%= BASE_URL %>admin/password-reset#<%= token %> +<%= BASE_URL %>account/password/reset#<%= token %> diff --git a/services/users.js b/services/users.js index d2dd60b0b..93d6fd753 100644 --- a/services/users.js +++ b/services/users.js @@ -20,14 +20,15 @@ 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'; @@ -37,21 +38,20 @@ const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; const SALT_ROUNDS = 10; // Create a redis client to use for authentication. -const Limit = require('./limit'); const loginRateLimiter = new Limit( 'loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, RECAPTCHA_WINDOW ); -// UsersService is the interface for the application to interact with the -// UserModel through. -class UsersService { +// Users is the interface for the application to interact with the +// User through. +class Users { /** * Returns a user (if found) for the given email address. */ static findLocalUser(email) { - return UserModel.findOne({ + return User.findOne({ profiles: { $elemMatch: { id: email.toLowerCase(), @@ -83,7 +83,7 @@ class UsersService { } static async setSuspensionStatus(id, until, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id }, { $set: { @@ -104,7 +104,7 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { throw new ErrNotFound(); } @@ -127,7 +127,7 @@ 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, @@ -140,7 +140,7 @@ class UsersService { } static async setBanStatus(id, status, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id, 'status.banned.status': { @@ -166,7 +166,7 @@ class UsersService { } ); if (!user) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (!user) { throw new ErrNotFound(); } @@ -180,7 +180,7 @@ class UsersService { // Check to see if the user was banned now and is currently banned. if (user.banned && status && message && message.length > 0) { - await UsersService.sendEmail(user, { + await Users.sendEmail(user, { template: 'plain', locals: { body: message, @@ -193,7 +193,7 @@ class UsersService { } static async setUsernameStatus(id, status, assignedBy = null) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id, 'status.username.status': { @@ -217,7 +217,7 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { throw new ErrNotFound(); } @@ -251,7 +251,7 @@ class UsersService { query.username = { $ne: username }; } - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( query, { $set: { @@ -272,7 +272,7 @@ class UsersService { } ); if (!user) { - user = await UsersService.findById(id); + user = await Users.findById(id); if (user === null) { throw new ErrNotFound(); } @@ -299,24 +299,11 @@ class UsersService { } static async setUsername(id, username, assignedBy) { - return UsersService._setUsername( - id, - username, - 'UNSET', - 'SET', - assignedBy, - true - ); + return Users._setUsername(id, username, 'UNSET', 'SET', assignedBy, true); } static async changeUsername(id, username, assignedBy) { - return UsersService._setUsername( - id, - username, - 'REJECTED', - 'CHANGED', - assignedBy - ); + return Users._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy); } /** @@ -340,7 +327,7 @@ class UsersService { * Sets or removes the recaptcha_required flag on a user's local profile. */ static flagForRecaptchaRequirement(email, required) { - return UserModel.update( + return User.update( { profiles: { $elemMatch: { @@ -372,11 +359,11 @@ class UsersService { const GROUP_ATTEMPTS = 50; // Cast the original username. - const castedName = UsersService.castUsername(username); + const castedName = Users.castUsername(username); const lowercaseUsername = castedName.toLowerCase(); // Try to see if our first guess has been taken. - const existingUserWithName = await UserModel.findOne({ + const existingUserWithName = await User.findOne({ lowercaseUsername, }); if (!existingUserWithName) { @@ -396,7 +383,7 @@ class UsersService { ); // See if any of these users aren't taken already. - const existingUsernames = (await UserModel.find( + const existingUsernames = (await User.find( { lowercaseUsername: { $in: lowercaseUsernameGuesses }, }, @@ -432,7 +419,7 @@ class UsersService { * @param {Function} done [description] */ static async findOrCreateExternalUser(ctx, id, provider, displayName) { - let user = await UserModel.findOne({ + let user = await User.findOne({ profiles: { $elemMatch: { id, @@ -447,10 +434,10 @@ class UsersService { // User does not exist and need to be created. // Create an initial username for the user. - let username = await UsersService.getInitialUsername(displayName); + let username = await Users.getInitialUsername(displayName); // The user was not found, lets create them! - user = new UserModel({ + user = new User({ username, lowercaseUsername: username.toLowerCase(), profiles: [{ id, provider }], @@ -480,11 +467,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', @@ -509,7 +492,7 @@ class UsersService { static async changePassword(id, password) { const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); - return UserModel.update( + return User.update( { id }, { $inc: { __v: 1 }, @@ -580,13 +563,13 @@ class UsersService { username = username.trim(); await Promise.all([ - UsersService.isValidUsername(username), - UsersService.isValidPassword(password), + Users.isValidUsername(username), + Users.isValidPassword(password), ]); const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); - let user = new UserModel({ + let user = new User({ username, lowercaseUsername: username.toLowerCase(), password: hashedPassword, @@ -630,11 +613,7 @@ class UsersService { * @param {String} role role to add */ static setRole(id, role) { - return UserModel.update( - { id }, - { $set: { role } }, - { runValidators: true } - ); + return User.update({ id }, { $set: { role } }, { runValidators: true }); } /** @@ -642,7 +621,7 @@ class UsersService { * @param {String} id user id (uuid) */ static findById(id) { - return UserModel.findOne({ id }); + return User.findOne({ id }); } /** @@ -652,7 +631,7 @@ class UsersService { */ static async findOrCreateByIDToken(id, token) { // Try to get the user. - let user = await UserModel.findOne({ id }); + let user = await User.findOne({ id }); // If the user was not found, try to look it up. if (user === null) { @@ -669,7 +648,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findByIdArray(ids) { - return UserModel.find({ + return User.find({ id: { $in: ids }, }); } @@ -679,7 +658,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findPublicByIdArray(ids) { - return UserModel.find( + return User.find( { id: { $in: ids }, }, @@ -699,7 +678,7 @@ class UsersService { email = email.toLowerCase(); const [user, domainValidated] = await Promise.all([ - UserModel.findOne({ profiles: { $elemMatch: { id: email } } }), + User.findOne({ profiles: { $elemMatch: { id: email } } }), DomainList.urlCheck(loc), ]); if (!user) { @@ -757,11 +736,11 @@ class UsersService { throw new Error('cannot verify an empty token'); } - const { userId, loc, version } = await UsersService.verifyToken(token, { + const { userId, loc, 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'); @@ -775,7 +754,7 @@ class UsersService { * @return {Promise} */ static count(query = {}) { - return UserModel.count(query); + return User.count(query); } /** @@ -783,7 +762,7 @@ class UsersService { * @return {Promise} */ static all() { - return UserModel.find(); + return User.find(); } /** @@ -791,7 +770,7 @@ class UsersService { * @return {Promise} */ static updateSettings(id, settings) { - return UserModel.update( + return User.update( { id, }, @@ -811,7 +790,7 @@ class UsersService { * @return {Promise} */ static addAction(item_id, user_id, action_type, metadata) { - return ActionsService.create({ + return Actions.create({ item_id, item_type: 'users', user_id, @@ -874,11 +853,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: { @@ -911,13 +890,11 @@ class UsersService { * @return {Promise} */ static async verifyEmailConfirmation(token) { - let { - userID, - email, - referer, - } = await UsersService.verifyEmailConfirmationToken(token); + let { userID, email, referer } = await Users.verifyEmailConfirmationToken( + token + ); - await UsersService.confirmEmail(userID, email); + await Users.confirmEmail(userID, email); return { userID, email, referer }; } @@ -926,7 +903,7 @@ class UsersService { * Marks the email on the user as confirmed. */ static confirmEmail(id, email) { - return UserModel.update( + return User.update( { id, profiles: { @@ -954,12 +931,12 @@ class UsersService { throw new Error('Users cannot ignore themselves'); } - const users = await UsersService.findByIdArray(usersToIgnore); + const users = await Users.findByIdArray(usersToIgnore); if (some(users, user => user.isStaff())) { throw new ErrCannotIgnoreStaff(); } - return UserModel.update( + return User.update( { id }, { $addToSet: { @@ -977,7 +954,7 @@ class UsersService { * @param {Array} usersToStopIgnoring Array of user IDs to stop ignoring */ static async stopIgnoringUsers(id, usersToStopIgnoring) { - await UserModel.update( + await User.update( { id }, { $pullAll: { @@ -988,7 +965,7 @@ class UsersService { } } -module.exports = UsersService; +module.exports = Users; // Extract all the tokenUserNotFound plugins so we can integrate with other // providers. diff --git a/test/e2e/globals.js b/test/e2e/globals.js index 56bcd3868..aeecd204e 100644 --- a/test/e2e/globals.js +++ b/test/e2e/globals.js @@ -27,5 +27,6 @@ module.exports = { body: 'This is a test comment', }, organizationName: 'Coral', + organizationContactEmail: 'coral@coralproject.net', }, }; diff --git a/test/e2e/page_objects/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/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/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') %>

+
+
+ +
+