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/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..bc642f93f 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();
@@ -109,20 +109,11 @@ router.put(
async (req, res, next) => {
const { token, password } = req.body;
- if (!password || password.length < 8) {
- return next(errors.ErrPasswordTooShort);
- }
-
try {
- let [user, redirect] = await UsersService.verifyPasswordResetToken(token);
-
- // Change the users' password.
- await UsersService.changePassword(user.id, password);
-
+ const { redirect } = await UsersService.resetPassword(token, password);
res.json({ redirect });
- } catch (e) {
- console.error(e);
- return next(errors.ErrNotAuthorized);
+ } catch (err) {
+ return next(err);
}
}
);
diff --git a/routes/api/v1/graph.js b/routes/api/v1/graph.js
index 7d13d4c92..40e87415b 100644
--- a/routes/api/v1/graph.js
+++ b/routes/api/v1/graph.js
@@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions));
if (process.env.NODE_ENV !== 'production') {
// Interactive graphiql interface.
router.use('/iql', staticTemplate, (req, res) => {
- res.render('graphiql', {
+ res.render('api/graphiql', {
endpointURL: 'api/v1/graph/ql',
});
});
diff --git a/routes/assets/index.js b/routes/dev/assets.js
similarity index 93%
rename from routes/assets/index.js
rename to routes/dev/assets.js
index 35fb6e9ed..cb1f27e43 100644
--- a/routes/assets/index.js
+++ b/routes/dev/assets.js
@@ -14,7 +14,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
return next(errors.ErrNotFound);
}
- res.render('article', {
+ res.render('dev/article', {
title: asset.title,
asset_id: asset.id,
asset_url: asset.url,
@@ -27,7 +27,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
});
router.get('/title/:asset_title', (req, res) => {
- return res.render('article', {
+ return res.render('dev/article', {
title: req.params.asset_title.split('-').join(' '),
asset_url: '',
asset_id: null,
@@ -42,7 +42,7 @@ router.get('/', async (req, res, next) => {
try {
const assets = await Assets.all(skip, limit);
- res.render('articles', {
+ res.render('dev/articles', {
assets: assets,
});
} catch (err) {
diff --git a/routes/dev/index.js b/routes/dev/index.js
new file mode 100644
index 000000000..ac72db254
--- /dev/null
+++ b/routes/dev/index.js
@@ -0,0 +1,25 @@
+const express = require('express');
+const url = require('url');
+const router = express.Router();
+
+const { MOUNT_PATH } = require('../../url');
+const SetupService = require('../../services/setup');
+const staticTemplate = require('../../middleware/staticTemplate');
+
+router.use('/assets', staticTemplate, require('./assets'));
+router.get('/', staticTemplate, async (req, res) => {
+ try {
+ await SetupService.isAvailable();
+ return res.redirect(url.resolve(MOUNT_PATH, 'admin/install'));
+ } catch (e) {
+ return res.render('dev/article', {
+ title: 'Coral Talk',
+ asset_url: '',
+ asset_id: '',
+ body: '',
+ basePath: '/static/embed/stream',
+ });
+ }
+});
+
+module.exports = router;
diff --git a/routes/index.js b/routes/index.js
index b6e46791e..2dbfd51ff 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -75,6 +75,7 @@ router.use(compression());
//==============================================================================
router.use('/admin', staticTemplate, require('./admin'));
+router.use('/account', staticTemplate, require('./account'));
router.use('/login', staticTemplate, require('./login'));
router.use('/embed', staticTemplate, require('./embed'));
@@ -114,22 +115,14 @@ router.use('/api', require('./api'));
//==============================================================================
if (process.env.NODE_ENV !== 'production') {
- router.use('/assets', staticTemplate, require('./assets'));
- router.get('/', staticTemplate, async (req, res) => {
- try {
- await SetupService.isAvailable();
- return res.redirect('/admin/install');
- } catch (e) {
- return res.render('article', {
- title: 'Coral Talk',
- asset_url: '',
- asset_id: '',
- body: '',
- basePath: '/static/embed/stream',
- });
- }
+ // In development, mount the /dev routes, as well as redirect the root url to
+ // the development route.
+ router.use('/dev', require('./dev'));
+ router.get('/', (req, res) => {
+ res.redirect(url.resolve(MOUNT_PATH, 'dev'), 302);
});
} else {
+ // In production, optionally redirect to the install if not ran, or the admin.
router.get('/', async (req, res, next) => {
try {
await SetupService.isAvailable();
diff --git a/services/logging.js b/services/logging.js
index 7ae3654b1..850283f40 100644
--- a/services/logging.js
+++ b/services/logging.js
@@ -2,13 +2,13 @@ const { version } = require('../package.json');
const path = require('path');
const { createLogger: createBunyanLogger, stdSerializers } = require('bunyan');
const { LOGGING_LEVEL, REVISION_HASH } = require('../config');
+const debug = require('bunyan-debug-stream');
// Streams enables the ability for development logs to be readable to a human,
// but will send JSON logs in production that's parsable by a system like ELK.
const streams = (() => {
// In development, use the debug stream printer.
if (process.env.NODE_ENV !== 'production') {
- const debug = require('bunyan-debug-stream');
return [
{
level: LOGGING_LEVEL,
diff --git a/services/mailer/templates/email-confirm.html.ejs b/services/mailer/templates/email-confirm.html.ejs
index 76a8ea47b..396386e21 100644
--- a/services/mailer/templates/email-confirm.html.ejs
+++ b/services/mailer/templates/email-confirm.html.ejs
@@ -1,3 +1,3 @@
<%= t('email.confirm.has_been_requested') %> <%= email %>.
-
<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>
+
<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>
<%= t('email.confirm.if_you_did_not') %>
diff --git a/services/mailer/templates/email-confirm.txt.ejs b/services/mailer/templates/email-confirm.txt.ejs
index b3cf28a01..41fabae46 100644
--- a/services/mailer/templates/email-confirm.txt.ejs
+++ b/services/mailer/templates/email-confirm.txt.ejs
@@ -4,6 +4,6 @@
<%= t('email.confirm.to_confirm') %>
- <%= BASE_URL %>admin/confirm-email#<%= token %>
+ <%= BASE_URL %>account/email/confirm#<%= token %>
<%= t('email.confirm.if_you_did_not') %>
diff --git a/services/mailer/templates/password-reset.html.ejs b/services/mailer/templates/password-reset.html.ejs
index c0ec4ea46..502781440 100644
--- a/services/mailer/templates/password-reset.html.ejs
+++ b/services/mailer/templates/password-reset.html.ejs
@@ -1,2 +1,2 @@
<%= t('email.password_reset.we_received_a_request') %>
-<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.
+<%= t('email.password_reset.if_you_did') %>
<%= t('email.password_reset.please_click') %>.
diff --git a/services/mailer/templates/password-reset.txt.ejs b/services/mailer/templates/password-reset.txt.ejs
index e8db4bab2..2b5e60a9b 100644
--- a/services/mailer/templates/password-reset.txt.ejs
+++ b/services/mailer/templates/password-reset.txt.ejs
@@ -1,3 +1,3 @@
<%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>:
-<%= BASE_URL %>admin/password-reset#<%= token %>
+<%= BASE_URL %>account/password/reset#<%= token %>
diff --git a/services/users.js b/services/users.js
index d2dd60b0b..657be737a 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,12 +127,12 @@ class UsersService {
// Check to see if the user was suspended now and is currently suspended.
if (user.suspended && message && message.length > 0) {
- await UsersService.sendEmail(user, {
+ await Users.sendEmail(user, {
template: 'plain',
locals: {
body: message,
},
- subject: 'Your account has been suspended',
+ subject: 'Your account has been suspended', // TODO: replace with translation
});
}
@@ -140,7 +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',
@@ -507,9 +490,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 },
@@ -580,13 +567,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 +617,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 +625,7 @@ class UsersService {
* @param {String} id user id (uuid)
*/
static findById(id) {
- return UserModel.findOne({ id });
+ return User.findOne({ id });
}
/**
@@ -652,7 +635,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 +652,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 +662,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 +682,7 @@ class UsersService {
email = email.toLowerCase();
const [user, domainValidated] = await Promise.all([
- UserModel.findOne({ profiles: { $elemMatch: { id: email } } }),
+ User.findOne({ profiles: { $elemMatch: { id: email } } }),
DomainList.urlCheck(loc),
]);
if (!user) {
@@ -746,28 +729,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 };
}
/**
@@ -775,7 +779,7 @@ class UsersService {
* @return {Promise}
*/
static count(query = {}) {
- return UserModel.count(query);
+ return User.count(query);
}
/**
@@ -783,7 +787,7 @@ class UsersService {
* @return {Promise}
*/
static all() {
- return UserModel.find();
+ return User.find();
}
/**
@@ -791,7 +795,7 @@ class UsersService {
* @return {Promise}
*/
static updateSettings(id, settings) {
- return UserModel.update(
+ return User.update(
{
id,
},
@@ -811,7 +815,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 +878,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 +915,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 +928,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 +956,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 +979,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 +990,7 @@ class UsersService {
}
}
-module.exports = UsersService;
+module.exports = Users;
// Extract all the tokenUserNotFound plugins so we can integrate with other
// providers.
diff --git a/test/e2e/globals.js b/test/e2e/globals.js
index 56bcd3868..aeecd204e 100644
--- a/test/e2e/globals.js
+++ b/test/e2e/globals.js
@@ -27,5 +27,6 @@ module.exports = {
body: 'This is a test comment',
},
organizationName: 'Coral',
+ organizationContactEmail: 'coral@coralproject.net',
},
};
diff --git a/test/e2e/page_objects/embedStream.js b/test/e2e/page_objects/embedStream.js
index be60fc8d0..de3a29973 100644
--- a/test/e2e/page_objects/embedStream.js
+++ b/test/e2e/page_objects/embedStream.js
@@ -28,7 +28,7 @@ module.exports = {
return this.section.comments;
},
navigateToAsset: function(asset) {
- this.api.url(`${this.api.launchUrl}/assets/title/${asset}`);
+ this.api.url(`${this.api.launchUrl}/dev/assets/title/${asset}`);
return this;
},
switchToIframe: function() {
@@ -44,7 +44,7 @@ module.exports = {
},
],
url: function() {
- return this.api.launchUrl;
+ return this.api.launchUrl + '/dev/';
},
elements: {
iframe: `#${iframeId}`,
diff --git a/test/e2e/page_objects/install.js b/test/e2e/page_objects/install.js
index 26b024993..1f27eabd7 100644
--- a/test/e2e/page_objects/install.js
+++ b/test/e2e/page_objects/install.js
@@ -20,6 +20,8 @@ module.exports = {
selector: '.talk-install-step-2',
elements: {
organizationNameInput: '.talk-install-step-2 #organizationName',
+ organizationContactEmailInput:
+ '.talk-install-step-2 #organizationContactEmail',
saveButton: '.talk-install-step-2-save-button',
},
},
diff --git a/test/e2e/specs/01_install.js b/test/e2e/specs/01_install.js
index a8a88566c..75e6a60eb 100644
--- a/test/e2e/specs/01_install.js
+++ b/test/e2e/specs/01_install.js
@@ -38,7 +38,12 @@ module.exports = {
step2
.waitForElementVisible('@organizationNameInput')
+ .waitForElementVisible('@organizationContactEmailInput', 5000)
.setValue('@organizationNameInput', testData.organizationName)
+ .setValue(
+ '@organizationContactEmailInput',
+ testData.organizationContactEmail
+ )
.waitForElementVisible('@saveButton')
.click('@saveButton');
},
diff --git a/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.email_confirmation') %>
+ <%= t('confirm_email.click_to_confirm') %>
+
+
+