diff --git a/.gitignore b/.gitignore index e8cb92653..0cab02aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ plugins/* !plugins/talk-plugin-notifications-digest-hourly !plugins/talk-plugin-offtopic !plugins/talk-plugin-permalink -!plugins/talk-plugin-profile-settings !plugins/talk-plugin-profile-data !plugins/talk-plugin-remember-sort !plugins/talk-plugin-respect diff --git a/README.md b/README.md index 6bdb7fda0..b0bc6e497 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ You’ve installed Talk on your server, and you’re preparing to launch it on y ## End-to-End Testing -Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com). +Talk uses [Nightwatch](http://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com). [](https://browserstack.com) diff --git a/bin/cli-users b/bin/cli-users index cfcbcd13a..a01980a20 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -287,8 +287,15 @@ async function createUser() { const { email, username, password, role } = answers; + const ctx = Context.forSystem(); + // Create the user. - const user = await UsersService.createLocalUser(email, password, username); + const user = await UsersService.createLocalUser( + ctx, + email, + password, + username + ); // Set the role. await UsersService.setRole(user.id, role); diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js index 38f211781..f3daeffc2 100644 --- a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js @@ -71,7 +71,6 @@ class OrganizationSettings extends React.Component { await this.props.savePending(); this.disableEditing(); }; - displayErrors = (errors = []) => (
{t('configure.organization_info_copy')}
@@ -126,7 +124,7 @@ class OrganizationSettings extends React.Component { [styles.editable]: this.state.editing, })} onChange={this.updateEmail} - value={settings.organizationContactEmail} + value={settings.organizationContactEmail || ''} id={t('configure.organization_contact_email')} readOnly={!this.state.editing} /> diff --git a/client/coral-admin/src/routes/Configure/containers/Configure.js b/client/coral-admin/src/routes/Configure/containers/Configure.js index 7f0951154..3d2789974 100644 --- a/client/coral-admin/src/routes/Configure/containers/Configure.js +++ b/client/coral-admin/src/routes/Configure/containers/Configure.js @@ -20,7 +20,8 @@ import OrganizationSettings from './OrganizationSettings'; import { withRouter } from 'react-router'; class ConfigureContainer extends React.Component { - state = { nextRoute: '' }; + nextRoute = ''; + unregisterLeaveHook = null; savePending = async () => { await this.props.updateSettings(this.props.pending); @@ -40,18 +41,16 @@ class ConfigureContainer extends React.Component { }; gotoNextRoute = () => { - const { nextRoute } = this.state; - if (nextRoute) { - this.props.router.push(nextRoute); - this.setState({ nextRoute: '' }); + if (this.nextRoute) { + this.props.router.push(this.nextRoute); + this.nextRoute = ''; } }; handleSectionChange = async section => { const nextRoute = `/admin/configure/${section}`; - - if (this.shouldShowSaveDialog()) { - await this.setState({ nextRoute }); + if (this.hasPendingData()) { + this.nextRoute = nextRoute; this.props.showSaveDialog(); } else { // Just go to the section @@ -59,20 +58,37 @@ class ConfigureContainer extends React.Component { } }; - shouldShowSaveDialog = () => { + navigationPrompt = e => { + if (this.hasPendingData()) { + const confirmationMessage = 'Changes that you made may not be saved.'; + e.returnValue = confirmationMessage; // Gecko, Trident, Chrome 34+ + return confirmationMessage; // Gecko, WebKit, Chrome <34 + } + }; + + hasPendingData = () => { return !!Object.keys(this.props.pending).length; }; routeLeave = ({ pathname }) => { - if (this.shouldShowSaveDialog()) { - this.setState({ nextRoute: pathname }); + if (this.hasPendingData()) { + this.nextRoute = pathname; this.props.showSaveDialog(); return false; } }; componentDidMount() { - this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave); + this.unregisterLeaveHook = this.props.router.setRouteLeaveHook( + this.props.route, + this.routeLeave + ); + window.addEventListener('beforeunload', this.navigationPrompt); + } + + componentWillUnmount() { + this.unregisterLeaveHook(); + window.removeEventListener('beforeunload', this.navigationPrompt); } render() { diff --git a/client/coral-embed-stream/src/tabs/profile/components/Profile.js b/client/coral-embed-stream/src/tabs/profile/components/Profile.js index 0bc90be0a..afac126d8 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/Profile.js +++ b/client/coral-embed-stream/src/tabs/profile/components/Profile.js @@ -4,13 +4,32 @@ import Slot from 'coral-framework/components/Slot'; import styles from './Profile.css'; import TabPanel from '../containers/TabPanel'; -const Profile = ({ username, emailAddress, root, slotPassthrough }) => { +const DefaultProfileHeader = ({ username, emailAddress }) => ( +{emailAddress}
: null} +{emailAddress}
: null} -<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= t('email.download.download_archive') %>
+<%= 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 index 138ea1088..940d62bad 100644 --- a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs +++ b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs @@ -1,3 +1,3 @@ <%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> - <%= BASE_URL %>account/download#<%= token %> + <%= downloadLandingURL %> diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js index dafc7dc42..b1756a34b 100644 --- a/plugins/talk-plugin-profile-data/server/mutators.js +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -6,18 +6,41 @@ const { ErrDeletionAlreadyScheduled, ErrDeletionNotScheduled, } = require('./errors'); -const { ErrNotAuthorized } = require('errors'); +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; -async function sendDownloadLink({ - user, - loaders: { Settings }, - connectors: { - errors, - secrets, - services: { Users, I18n, Limit }, - models: { User }, - }, -}) { // downloadLinkLimiter can be used to limit downloads for the user's data to // once every 7 days. const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d'); @@ -26,7 +49,7 @@ async function sendDownloadLink({ // 7 days. const attempts = await downloadLinkLimiter.get(user.id); if (attempts && attempts >= 1) { - throw errors.ErrMaxRateLimit; + throw new ErrMaxRateLimit(); } // Check if the lastAccountDownload time is within 7 days. @@ -36,7 +59,7 @@ async function sendDownloadLink({ .add(7, 'days') .isAfter(moment()) ) { - throw errors.ErrMaxRateLimit; + throw new ErrMaxRateLimit(); } // The account currently does not have a download link, let's record the @@ -44,21 +67,18 @@ async function sendDownloadLink({ // now. await downloadLinkLimiter.test(user.id); - // Generate a token for the download link. - const token = await secrets.jwt.sign( - { user: user.id }, - { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT } - ); - 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: { - token, + downloadLandingURL, organizationName, now, }, @@ -125,3 +145,20 @@ module.exports = ctx => cancelDeletion: () => Promise.reject(new ErrNotAuthorized()), }, }; +// 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 index a90c617e2..9a5a90638 100644 --- a/plugins/talk-plugin-profile-data/server/resolvers.js +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -11,6 +11,9 @@ module.exports = { cancelAccountDeletion: async (_, args, { mutators: { User } }) => { await User.cancelDeletion(); }, + downloadUser: async (_, { id }, { mutators: { User } }) => ({ + archiveURL: await User.download(id), + }), }, User: { lastAccountDownload: (user, args, { user: currentUser }) => { diff --git a/plugins/talk-plugin-profile-data/server/router.js b/plugins/talk-plugin-profile-data/server/router.js index 00061c173..2a2e0161a 100644 --- a/plugins/talk-plugin-profile-data/server/router.js +++ b/plugins/talk-plugin-profile-data/server/router.js @@ -20,14 +20,15 @@ async function verifyDownloadToken( // loadCommentsBatch will load a batch of the comments and write them to the // stream. -async function loadCommentsBatch(ctx, csv, variables = {}) { +async function loadCommentsBatch(ctx, csv, variables) { let result = await ctx.graphql( ` - query GetMyComments($cursor: Cursor) { - me { + query GetMyComments($userID: ID!, $cursor: Cursor) { + user(id: $userID) { comments(query: { limit: 100, - cursor: $cursor + cursor: $cursor, + statuses: null }) { hasNextPage endCursor @@ -50,7 +51,7 @@ async function loadCommentsBatch(ctx, csv, variables = {}) { throw result.errors; } - for (const comment of get(result, 'data.me.comments.nodes', [])) { + 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'), @@ -60,12 +61,12 @@ async function loadCommentsBatch(ctx, csv, variables = {}) { ]); } - return pick(result.data.me.comments, ['hasNextPage', 'endCursor']); + 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, archive, latestContentDate) { +async function loadComments(ctx, userID, archive, latestContentDate) { // Create all the csv writers that'll write the data to the archive. const csv = stringify(); @@ -78,12 +79,14 @@ async function loadComments(ctx, archive, latestContentDate) { // 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, }); } @@ -98,11 +101,21 @@ module.exports = router => { // /api/v1/account/download will send back a zipped archive of the users // account. - router.post( + router.all( '/api/v1/account/download', express.urlencoded({ extended: false }), async (req, res, next) => { - const { token = null, check = false } = req.body; + 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. @@ -120,7 +133,7 @@ module.exports = router => { return; } - const { connectors: { services: { Users } } } = req.context; + const { connectors: { graph: { Context }, errors } } = req.context; try { // Pull the userID and the date that the token was issued out of the @@ -130,25 +143,31 @@ module.exports = router => { 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); - // Grab the user that we're generating the export from. We'll use it to - // create a new context. - const user = await Users.findById(userID); - - // Base a new context off of the new user. - const ctx = req.context.masqueradeAs(user); - - // Get the current user's username. We need it for the generated filenames. - const result = await ctx.graphql('{ me { username } }'); - if (result.errors) { - throw result.errors; - } - const username = get(result, 'data.me.username'); - // 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`; @@ -167,7 +186,7 @@ module.exports = router => { archive.pipe(res); // Load the comments csv up with the user's comments. - await loadComments(ctx, archive, latestContentDate); + 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 diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.graphql b/plugins/talk-plugin-profile-data/server/typeDefs.graphql index c785fb2cc..62e27cae4 100644 --- a/plugins/talk-plugin-profile-data/server/typeDefs.graphql +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -41,6 +41,18 @@ type CancelAccountDeletionResponse implements Response { errors: [UserError!] } +# DownloadUserResponse contaisn the account download archiveURL that can be used +# to directly download a zip file containing the user data. +type DownloadUserResponse implements Response { + + # archiveURL is the link that can be used within the next 1 hour to download a + # users archive. + archiveURL: String + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + type RootMutation { # requestDownloadLink will request a download link be sent to the primary @@ -54,4 +66,8 @@ type RootMutation { # cancelAccountDeletion will cancel a pending account deletion that was # previously scheduled. cancelAccountDeletion: CancelAccountDeletionResponse + + # downloadUser will provide an account download for the indicated User. This + # mutation requires the ADMIN role. + downloadUser(id: ID!): DownloadUserResponse } diff --git a/plugins/talk-plugin-profile-data/translations.yml b/plugins/talk-plugin-profile-data/translations.yml index 6637c6958..d1059d470 100644 --- a/plugins/talk-plugin-profile-data/translations.yml +++ b/plugins/talk-plugin-profile-data/translations.yml @@ -1,7 +1,7 @@ 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 seperated value (or .csv) file that you can easily import into your favorite spreadsheet application." + 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" diff --git a/plugins/talk-plugin-rich-text/README.md b/plugins/talk-plugin-rich-text/README.md index a69e5c8dc..cac44438e 100644 --- a/plugins/talk-plugin-rich-text/README.md +++ b/plugins/talk-plugin-rich-text/README.md @@ -16,6 +16,9 @@ Enables secure rich text support server-side. Add `"talk-plugin-rich-text"` to the `plugins.json` in your Talk installation. This plugin provides a server and a client side implementation. +###### Note: Possible plugin conflict +The plugin `talk-plugin-comment-content` will prevent this plugin from rendering comments with rich text styling and is not needed if this plugin is enabled. + ## Server implementation ### How does this work? @@ -43,11 +46,11 @@ Settings for highlighting links. These will only apply if `higlightLinks` is set #### `dompurify` -Rules to sanitize html input. We use [DOMPurify] (https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings] (https://github.com/cure53/DOMPurify) +Rules to sanitize html input. We use [DOMPurify](https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings](https://github.com/cure53/DOMPurify) #### `jsdom` -In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings. +In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings. ## Client implementation @@ -58,10 +61,10 @@ This plugin contains 2 important components: - The Editor (`./components/Editor.js`) - The Comment Content Renderer (`./components/CommentContent.js`) -The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API. +The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API. If you check our `index.js` you will notice that we inject this editor in the -`commentBox` slot. We do this to replace the core comment box with this one. +`commentBox` slot. We do this to replace the core comment box with this one. Now, in order to render the new styled comments we need a comment renderer. For this task we will have to replace our core comment renderer by using the diff --git a/plugins/talk-plugin-sort-most-liked/client/translations.yml b/plugins/talk-plugin-sort-most-liked/client/translations.yml index 963561cfd..710519f50 100644 --- a/plugins/talk-plugin-sort-most-liked/client/translations.yml +++ b/plugins/talk-plugin-sort-most-liked/client/translations.yml @@ -12,7 +12,7 @@ de: label: Beliebteste zuerst es: talk-plugin-sort-most-liked: - label: Most liked first + label: Más valoradas primero fr: talk-plugin-sort-most-liked: label: Most liked first diff --git a/plugins/talk-plugin-sort-most-loved/client/translations.yml b/plugins/talk-plugin-sort-most-loved/client/translations.yml index dfb24ff44..f9bf60d18 100644 --- a/plugins/talk-plugin-sort-most-loved/client/translations.yml +++ b/plugins/talk-plugin-sort-most-loved/client/translations.yml @@ -12,7 +12,7 @@ de: label: Häufigste "Ich liebe es" zuerst es: talk-plugin-sort-most-loved: - label: Most loved first + label: Más amadas primero fr: talk-plugin-sort-most-loved: label: Most loved first diff --git a/plugins/talk-plugin-sort-most-replied/client/translations.yml b/plugins/talk-plugin-sort-most-replied/client/translations.yml index 1325d9dd1..2249a5437 100644 --- a/plugins/talk-plugin-sort-most-replied/client/translations.yml +++ b/plugins/talk-plugin-sort-most-replied/client/translations.yml @@ -12,7 +12,7 @@ de: label: Häufigste Antworten zuerst es: talk-plugin-sort-most-replied: - label: Most replied first + label: Más respondidas primero fr: talk-plugin-sort-most-replied: label: Most replied first diff --git a/plugins/talk-plugin-sort-most-respected/client/translations.yml b/plugins/talk-plugin-sort-most-respected/client/translations.yml index 782145237..5ebf85b90 100644 --- a/plugins/talk-plugin-sort-most-respected/client/translations.yml +++ b/plugins/talk-plugin-sort-most-respected/client/translations.yml @@ -12,7 +12,7 @@ de: label: Häufigste "Respektiert" zuerst es: talk-plugin-sort-most-respected: - label: Most respected first + label: Más respetadas primero fr: talk-plugin-sort-most-respected: label: Most respected first diff --git a/plugins/talk-plugin-sort-most-upvoted/client/translations.yml b/plugins/talk-plugin-sort-most-upvoted/client/translations.yml index 7b3c7c29f..6a673a767 100644 --- a/plugins/talk-plugin-sort-most-upvoted/client/translations.yml +++ b/plugins/talk-plugin-sort-most-upvoted/client/translations.yml @@ -1,3 +1,6 @@ en: talk-plugin-sort-most-upvoted: label: Most upvoted first +es: + talk-plugin-sort-oldest: + label: Más votadas primero diff --git a/plugins/talk-plugin-sort-newest/client/translations.yml b/plugins/talk-plugin-sort-newest/client/translations.yml index 294a1a070..663088395 100644 --- a/plugins/talk-plugin-sort-newest/client/translations.yml +++ b/plugins/talk-plugin-sort-newest/client/translations.yml @@ -12,7 +12,7 @@ de: label: Neueste zuerst es: talk-plugin-sort-newest: - label: Newest first + label: Más nuevas primero fr: talk-plugin-sort-newest: label: Newest first diff --git a/plugins/talk-plugin-sort-oldest/client/translations.yml b/plugins/talk-plugin-sort-oldest/client/translations.yml index 7a6cd6e96..5dfe656a1 100644 --- a/plugins/talk-plugin-sort-oldest/client/translations.yml +++ b/plugins/talk-plugin-sort-oldest/client/translations.yml @@ -12,7 +12,7 @@ de: label: Älteste zuerst es: talk-plugin-sort-oldest: - label: Oldest first + label: Más viejas primero fr: talk-plugin-sort-oldest: label: Oldest first diff --git a/plugins/talk-plugin-viewing-options/client/translations.yml b/plugins/talk-plugin-viewing-options/client/translations.yml index 9586263e0..e3516ad7f 100644 --- a/plugins/talk-plugin-viewing-options/client/translations.yml +++ b/plugins/talk-plugin-viewing-options/client/translations.yml @@ -21,8 +21,8 @@ de: es: talk-plugin-viewing-options: viewing_options: "Opciones de visualización" - sort: Sorting - filter: Filtering + sort: Ordenado por + filter: Filtrado por fr: talk-plugin-viewing-options: viewing_options: "Viewing Options" diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js index 3909d5dce..176728a8c 100644 --- a/routes/api/v1/account.js +++ b/routes/api/v1/account.js @@ -88,7 +88,7 @@ router.post('/password/reset', async (req, res, next) => { locals: { token, }, - subject: 'Password Reset', + subject: res.locals.t('email.password_reset.subject'), email, }); } @@ -109,20 +109,11 @@ router.put( async (req, res, next) => { const { token, password } = req.body; - if (!password || password.length < 8) { - return next(errors.ErrPasswordTooShort); - } - try { - let [user, redirect] = await UsersService.verifyPasswordResetToken(token); - - // Change the users' password. - await UsersService.changePassword(user.id, password); - + const { redirect } = await UsersService.resetPassword(token, password); res.json({ redirect }); - } catch (e) { - console.error(e); - return next(errors.ErrNotAuthorized); + } catch (err) { + return next(err); } } ); diff --git a/services/mongoose.js b/services/mongoose.js index 4e242cf47..167b0d45b 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -66,6 +66,7 @@ if (CREATE_MONGO_INDEXES) { require('../models/action'); require('../models/asset'); require('../models/comment'); + require('../models/migration'); require('../models/setting'); require('../models/user'); require('./migration'); diff --git a/services/users.js b/services/users.js index 93d6fd753..e53df4119 100644 --- a/services/users.js +++ b/services/users.js @@ -1,4 +1,5 @@ const uuid = require('uuid'); +const moment = require('moment'); const bcrypt = require('bcryptjs'); const { ErrMaxRateLimit, @@ -132,7 +133,7 @@ class Users { locals: { body: message, }, - subject: 'Your account has been suspended', + subject: 'Your account has been suspended', // TODO: replace with translation }); } @@ -234,57 +235,74 @@ class Users { return user; } - static async _setUsername( - id, - username, - fromStatus, - toStatus, - assignedBy, - resetAllowed = false - ) { + static async setUsername(id, username, assignedBy) { try { + const oldestEditTime = moment() + .subtract(14, 'days') + .toDate(); + + // A username can be set if: + // + // - The previous status was 'UNSET' + // - The username has not been changed within the last 14 days. const query = { id, - 'status.username.status': fromStatus, - }; - if (!resetAllowed) { - query.username = { $ne: username }; - } - - let user = await User.findOneAndUpdate( - query, - { - $set: { - username, - lowercaseUsername: username.toLowerCase(), - 'status.username.status': toStatus, + $or: [ + { + 'status.username.status': 'UNSET', }, - $push: { - 'status.username.history': { - status: toStatus, - assigned_by: assignedBy, - created_at: Date.now(), - }, + { + 'status.username.status': { $in: ['APPROVED', 'SET'] }, + $or: [ + { + 'status.username.history.created_at': { + $lte: oldestEditTime, + }, + }, + { + 'status.username.history': [], + }, + { + 'status.username.history': { $exists: false }, + }, + ], + }, + ], + }; + + const update = { + $set: { + username, + lowercaseUsername: username.toLowerCase(), + 'status.username.status': 'SET', + }, + $push: { + 'status.username.history': { + status: 'SET', + assigned_by: assignedBy, + created_at: Date.now(), }, }, - { - new: true, - } - ); + }; + + let user = await User.findOneAndUpdate(query, update, { + new: true, + }); if (!user) { user = await Users.findById(id); if (user === null) { throw new ErrNotFound(); } - if (user.status.username.status !== fromStatus) { + if ( + !['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) || + user.status.username.history.some(({ created_at }) => + moment(created_at).isAfter(oldestEditTime) + ) + ) { throw new ErrPermissionUpdateUsername(); } - if (!resetAllowed && user.username === username) { - throw new ErrSameUsernameProvided(); - } - throw new Error('edit username failed for an unexpected reason'); } @@ -298,12 +316,57 @@ class Users { } } - static async setUsername(id, username, assignedBy) { - return Users._setUsername(id, username, 'UNSET', 'SET', assignedBy, true); - } - static async changeUsername(id, username, assignedBy) { - return Users._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy); + try { + const query = { + id, + username: { $ne: username }, + 'status.username.status': 'REJECTED', + }; + + const update = { + $set: { + username, + lowercaseUsername: username.toLowerCase(), + 'status.username.status': 'CHANGED', + }, + $push: { + 'status.username.history': { + status: 'CHANGED', + assigned_by: assignedBy, + created_at: Date.now(), + }, + }, + }; + + let user = await User.findOneAndUpdate(query, update, { + new: true, + }); + if (!user) { + user = await Users.findById(id); + if (user === null) { + throw new ErrNotFound(); + } + + if (user.status.username.status !== 'REJECTED') { + throw new ErrPermissionUpdateUsername(); + } + + if (user.username === username) { + throw new ErrSameUsernameProvided(); + } + + throw new Error('edit username failed for an unexpected reason'); + } + + return user; + } catch (err) { + if (err.code === 11000) { + throw new ErrUsernameTaken(); + } + + throw err; + } } /** @@ -490,6 +553,10 @@ class Users { } static async changePassword(id, password) { + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); return User.update( @@ -725,18 +792,13 @@ class Users { }); } - /** - * 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 Users.verifyToken(token, { + const { userId, loc: redirect, version } = await Users.verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT, }); @@ -746,7 +808,33 @@ class Users { 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 }; } /** diff --git a/test/server/services/users.js b/test/server/services/users.js index e8a0172f7..2b9210f83 100644 --- a/test/server/services/users.js +++ b/test/server/services/users.js @@ -2,6 +2,8 @@ const UsersService = require('../../../services/users'); const SettingsService = require('../../../services/settings'); const mailer = require('../../../services/mailer'); const Context = require('../../../graph/context'); +const timekeeper = require('timekeeper'); +const moment = require('moment'); const chai = require('chai'); chai.use(require('chai-as-promised')); @@ -302,6 +304,62 @@ describe('services.UsersService', () => { await UsersService[func](user.id, user.username); } }); + + if (func === 'setUsername') { + it('should let a user set their username from UNSET', async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, 'UNSET'); + await UsersService.setUsername(user.id, 'new_username', null); + }); + + describe('time based', () => { + afterEach(() => { + timekeeper.reset(); + }); + + ['SET', 'APPROVED'].forEach(status => { + it(`should not allow users to change their username if it was changed within 14 of today from ${status}`, async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, status); + + timekeeper.travel( + moment() + .add(5, 'days') + .toDate() + ); + + try { + await UsersService.setUsername(user.id, 'new_username', null); + throw new Error('edit was processed successfully'); + } catch (err) { + expect(err).have.property( + 'translation_key', + 'EDIT_USERNAME_NOT_AUTHORIZED' + ); + } + }); + + it(`allows users to change their username if it was changed 14 days before today from ${status}`, async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, status); + + timekeeper.travel( + moment() + .add(15, 'days') + .toDate() + ); + + await UsersService.setUsername(user.id, 'new_username', null); + }); + }); + }); + } }); }); diff --git a/views/admin.ejs b/views/admin.ejs index 979e2ec41..b8f775d79 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -11,8 +11,8 @@ - <%- include partials/head %> + diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs index 50faaf5c2..2027f6069 100644 --- a/views/embed/stream.ejs +++ b/views/embed/stream.ejs @@ -2,9 +2,9 @@ + <%- include ../partials/head %> - <%- include ../partials/head %> diff --git a/views/login.ejs b/views/login.ejs index 7af820a9c..c5a86c2fb 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -4,8 +4,8 @@ - <%- include partials/head %> +