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). [![Browserstack](/public/img/browserstack_logo.png)](https://browserstack.com) diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js index 2bbe1a230..f3daeffc2 100644 --- a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js @@ -84,7 +84,6 @@ class OrganizationSettings extends React.Component { render() { const { settings, slotPassthrough, canSave } = this.props; const hasErrors = this.state.errors.length; - return (

{t('configure.organization_info_copy')}

@@ -125,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 }) => ( +
+

{username}

+ {emailAddress ?

{emailAddress}

: null} +
+); + +DefaultProfileHeader.propTypes = { + username: PropTypes.string, + emailAddress: PropTypes.string, +}; + +const Profile = ({ id, username, emailAddress, root, slotPassthrough }) => { return (
-
-

{username}

- {emailAddress ?

{emailAddress}

: null} -
+
@@ -18,6 +37,7 @@ const Profile = ({ username, emailAddress, root, slotPassthrough }) => { }; Profile.propTypes = { + id: PropTypes.string, username: PropTypes.string, emailAddress: PropTypes.string, root: PropTypes.object, diff --git a/client/coral-embed-stream/src/tabs/profile/containers/Profile.js b/client/coral-embed-stream/src/tabs/profile/containers/Profile.js index 84584ae5d..0272c1535 100644 --- a/client/coral-embed-stream/src/tabs/profile/containers/Profile.js +++ b/client/coral-embed-stream/src/tabs/profile/containers/Profile.js @@ -30,6 +30,7 @@ class ProfileContainer extends Component { return ( { export const isBanned = user => { return get(user, 'state.status.banned.status'); }; + +/** + * canUsernameBeUpdated + * retrieves boolean whether a username can be updated or not + */ + +export const canUsernameBeUpdated = status => { + const oldestEditTime = moment() + .subtract(14, 'days') + .toDate(); + + return !status.username.history.some(({ created_at }) => + moment(created_at).isAfter(oldestEditTime) + ); +}; diff --git a/client/coral-ui/components/PopupMenu.css b/client/coral-ui/components/PopupMenu.css index 35544fbad..e6a084736 100644 --- a/client/coral-ui/components/PopupMenu.css +++ b/client/coral-ui/components/PopupMenu.css @@ -9,7 +9,7 @@ box-sizing: border-box; background: white; border-radius: 3px; - padding: 20px 10px; + padding: 10px 10px; z-index: 300; right: 1%; } diff --git a/docs/source/03-03-product-guide-moderator-features.md b/docs/source/03-03-product-guide-moderator-features.md index 9fbda6a18..abb2c454a 100644 --- a/docs/source/03-03-product-guide-moderator-features.md +++ b/docs/source/03-03-product-guide-moderator-features.md @@ -170,8 +170,9 @@ moderators. All your team and commenters show in the People sub-tab. From here, you can manage your team members’ roles (Admins, Moderators, Staff), as well as search -for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ### -Configure +for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). + +### Configure See [Configuring Talk](/talk/configuring-talk/). diff --git a/docs/source/api/slots.md b/docs/source/api/slots.md index da57c0ad6..a2e7cc04d 100644 --- a/docs/source/api/slots.md +++ b/docs/source/api/slots.md @@ -33,8 +33,7 @@ By default, Talk has various plugins provided by default. We can see this in `pl "talk-plugin-sort-most-respected", "talk-plugin-sort-newest", "talk-plugin-sort-oldest", - "talk-plugin-viewing-options", - "talk-plugin-profile-settings" + "talk-plugin-viewing-options" ] } ``` diff --git a/docs/source/integrating/configuring-comment-stream.md b/docs/source/integrating/configuring-comment-stream.md index 110560de0..613535a13 100644 --- a/docs/source/integrating/configuring-comment-stream.md +++ b/docs/source/integrating/configuring-comment-stream.md @@ -65,10 +65,6 @@ First, you'll enable `talk-plugin-author-menu`, as this houses the Ignore button And then we will enable the Ignore User plugin: `talk-plugin-ignore-user`. -And finally, we will need to enable Profile Settings; this is the tab on My Profile > Settings where commenters can manage their Ignored Users list. - -`talk-plugin-profile-settings` - ### Featured Comments To enable the featuring of comments, you'll need to activate `talk-plugin-featured-comments`. If you would like the Featured Comments tab to be the default tab you land on for the stream, you will need to set the default tab ENV variable: diff --git a/docs/source/integrating/notifications.md b/docs/source/integrating/notifications.md index 470632907..a5f625083 100644 --- a/docs/source/integrating/notifications.md +++ b/docs/source/integrating/notifications.md @@ -36,10 +36,6 @@ Adding the `talk-plugin-notifications` plugin will also enable the `notification See https://github.com/coralproject/talk/blob/8b669a31c551a042f0f079d8cfc16825673eb8f0/plugins/talk-plugin-notifications-reply/index.js for an example. -### Commenter Notification Settings - -Note that notifications REQUIRE the `talk-plugin-profile-settings` plugin; this is where on My Profile commenters will enable and manage their notification settings. - ### Notification Categories Talk currently supports the following Notifications options out of the box: diff --git a/locales/en.yml b/locales/en.yml index f8b9b9ce2..393d01e23 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -123,7 +123,7 @@ en: custom_css_url: "Custom CSS URL" custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external." days: Days - description: "As an admin, you can customize the settings for the comment stream for this story:" + description: "Change the comment settings on this story." domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)." domain_list_title: "Permitted Domains" edit_info: "Edit Info" @@ -164,7 +164,7 @@ en: tech_settings: "Tech Settings" organization_information: "Organization information" organization_info_copy: "We use this information in email notifications generated by Talk. This connects the messages to your organization, and provides a way for users to contact you if they have an issue with their account." - organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpuse. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked." + organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpose. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked." organization_details: "Organization Details" organization_name: "Organization Name" organization_contact_email: "Organization Contact Email" @@ -218,6 +218,7 @@ en: we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email." if_you_did: "If you did," please_click: "please click here to reset password" + subject: "Password reset" password_change: subject: "{0} password change" body: "The password on your account has been changed.\n\nIf you did not request this change, please contact us at {0}." @@ -236,7 +237,7 @@ en: RATE_LIMIT_EXCEEDED: "Rate limit exceeded" USERNAME_IN_USE: "Username already in use" USERNAME_REQUIRED: "Must input a username" - EMAIL_NOT_VERIFIED: "E-mail address not verified" + EMAIL_NOT_VERIFIED: "Email address not verified" EDIT_WINDOW_ENDED: "You can no longer edit this comment. The time window to do so has expired." EDIT_USERNAME_NOT_AUTHORIZED: "You do not have permission to update your username." SAME_USERNAME_PROVIDED: "You must submit a different username." @@ -249,11 +250,11 @@ en: ALREADY_EXISTS: "Resource already exists" INVALID_ASSET_URL: "Assert URL is invalid" CANNOT_IGNORE_STAFF: "Cannot ignore Staff members." - email: "Not a valid E-Mail" + email: "Please enter a valid email." confirm_password: "Passwords don't match. Please check again" network_error: "Failed to connect to server. Check your internet connection and try again." - email_not_verified: "E-mail address {0} not verified." - email_password: "E-mail and/or password combination incorrect." + email_not_verified: "Email address {0} not verified." + email_password: "Email and/or password combination incorrect." organization_name: "Organization name must only contain letters or numbers." organization_contact_email: "Organization email is not valid." password: "Password must be at least 8 characters" @@ -441,7 +442,7 @@ en: title_reject: "We noticed you rejected a username" suspend_user: "Suspend User" yes_suspend: "Yes suspend" - email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns." + email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please email us if you have any questions or concerns." write_message: "Write a message" send: Send thank_you: "We value your safety and feedback. A moderator will review your report." diff --git a/locales/es.yml b/locales/es.yml index f5cade3f3..628dd8faa 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -120,7 +120,7 @@ es: custom_css_url: "URL CSS a medida" custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa." days: Días - description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo" + description: "Modificar la configuración de los comentarios en este artículo." domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)." domain_list_title: "Dominios Permitidos" edit_info: "Editar Información" @@ -215,6 +215,7 @@ es: we_received_a_request: "Recibimos un pedido para resetear su contraseña. Si no ha pedido ese cambio, por favor ignorar el correo. " if_you_did: "Si lo hizo," please_click: "por favor cliquea aqui para resetear la contraseña." + subject: "Recuperar contraseña" embedlink: copy: "Copiar al portapapeles" error: diff --git a/locales/fr.yml b/locales/fr.yml index 2230549e7..1e9e0e496 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -121,7 +121,7 @@ fr: custom_css_url: "URL CSS personnalisée" custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe." days: Journées - description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément." + description: "Personnaliser les paramètres du fil de commentaires pour cet élément." domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)." domain_list_title: "Domaines autorisés" edit_comment_timeframe_heading: "Modifier l'horodatage des commentaires" diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index c236bdb4c..4b7b5c3e3 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -120,7 +120,7 @@ pt_BR: custom_css_url: "URL para CSS customizado" custom_css_url_desc: "URL de uma folha de estilo CSS para substituir os estilos padrão dos comentários embutidos. Pode ser interno ou externo." days: Dias - description: "Como administrador, você pode personalizar as configurações da lista de comentários para esta história:" + description: "Personalizar as configurações da lista de comentários para esta história." domain_list_text: "Insira os domínios que você gostaria de permitir para o Talk, como seus ambientes de desenvolvimento, teste ou produção (ej. localhost:3000 staging.domain.com domain.com)." domain_list_title: "Domínios permitidos" edit_comment_timeframe_heading: "Período de tempo para editar comentários" diff --git a/models/schema/index.js b/models/schema/index.js index 976b437c0..c33402078 100644 --- a/models/schema/index.js +++ b/models/schema/index.js @@ -1,5 +1,3 @@ -const { CREATE_MONGO_INDEXES } = require('../../config'); - const Action = require('./action'); const Asset = require('./asset'); const Comment = require('./comment'); @@ -7,15 +5,4 @@ const Migration = require('./migration'); const Setting = require('./setting'); const User = require('./user'); -const schema = { Action, Asset, Comment, Migration, Setting, User }; - -// Provide the schema to each of the plugins so that they can add in indexes if -// it is enabled. -if (CREATE_MONGO_INDEXES) { - const plugins = require('../../services/plugins'); - plugins.get('server', 'indexes').map(({ indexes }) => { - indexes(schema); - }); -} - -module.exports = schema; +module.exports = { Action, Asset, Comment, Migration, Setting, User }; diff --git a/perms/reducers/mutation.js b/perms/reducers/mutation.js index d0841ef99..44f66cf88 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -1,4 +1,5 @@ -const { isString } = require('lodash'); +const { get, isString } = require('lodash'); +const moment = require('moment'); const { check } = require('../utils'); const types = require('../constants'); @@ -12,11 +13,21 @@ module.exports = (user, perm) => { isString(user.password) && user.password.length > 0 ); + case types.CHANGE_USERNAME: return user.status.username.status === 'REJECTED'; - case types.SET_USERNAME: - return user.status.username.status === 'UNSET'; + case types.SET_USERNAME: { + // Only users who have their usernames rejected or those users who + // not changed their usernames within 14 days can change their usernames. + const deadline = moment().subtract(14, 'days'); + return ( + user.status.username.status === 'UNSET' || + get(user, 'status.username.history', []).every(({ created_at }) => + moment(created_at).isBefore(deadline) + ) + ); + } case types.CREATE_COMMENT: case types.CREATE_ACTION: diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 41ede8445..d7ca5fce9 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -26,5 +26,6 @@ export { withStopIgnoringUser, withSetCommentStatus, withChangePassword, + withChangeUsername, } from 'coral-framework/graphql/mutations'; export { compose } from 'recompose'; diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index da075d76c..e90a06122 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -2,23 +2,24 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants'); const { ErrNotFound, ErrAlreadyExists } = require('../../../errors'); const pluralize = require('pluralize'); const sc = require('snake-case'); -// const { CREATE_MONGO_INDEXES } = require('../../../config'); +const { CREATE_MONGO_INDEXES } = require('../../../config'); +const Comment = require('models/comment'); function getReactionConfig(reaction) { reaction = reaction.toLowerCase(); - // if (CREATE_MONGO_INDEXES) { - // // Create the index on the comment model based on the reaction config. - // CommentModel.collection.createIndex( - // { - // created_at: 1, - // [`action_counts.${sc(reaction)}`]: 1, - // }, - // { - // background: true, - // } - // ); - // } + if (CREATE_MONGO_INDEXES) { + // Create the index on the comment model based on the reaction config. + Comment.collection.createIndex( + { + created_at: 1, + [`action_counts.${sc(reaction)}`]: 1, + }, + { + background: true, + } + ); + } const reactionPlural = pluralize(reaction); const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1); @@ -127,17 +128,6 @@ function getReactionConfig(reaction) { return { typeDefs, - indexes: ({ Comment }) => { - Comment.index( - { - created_at: 1, - [`action_counts.${sc(reaction)}`]: 1, - }, - { - background: true, - } - ); - }, context: { Sort: () => ({ Comments: { diff --git a/plugins/talk-plugin-auth/client/index.js b/plugins/talk-plugin-auth/client/index.js index 039f0f936..8fedb8033 100644 --- a/plugins/talk-plugin-auth/client/index.js +++ b/plugins/talk-plugin-auth/client/index.js @@ -5,6 +5,7 @@ import translations from './translations.yml'; import Login from './login/containers/Main'; import reducer from './login/reducer'; import ChangePassword from './profile-settings/containers/ChangePassword'; +import ChangeUsername from './profile-settings/containers/ChangeUsername'; export default { reducer, @@ -12,6 +13,7 @@ export default { slots: { stream: [UserBox, SignInButton, SetUsernameDialog], login: [Login], + profileHeader: [ChangeUsername], profileSettings: [ChangePassword], }, }; diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.js b/plugins/talk-plugin-auth/client/login/components/SignUp.js index 83f81f81d..96dadcd67 100644 --- a/plugins/talk-plugin-auth/client/login/components/SignUp.js +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.js @@ -103,8 +103,7 @@ class SignUp extends React.Component { /> {passwordError && ( - {' '} - Password must be at least 8 characters.{' '} + {t('talk-plugin-auth.login.password_error')} )} {editing && ( -
+ - + )} {editing ? (
diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.css b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.css new file mode 100644 index 000000000..5b4631ecf --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.css @@ -0,0 +1,122 @@ +.container { + margin-bottom: 20px; + display: flex; + position: relative; + color: #202020; + padding: 10px; + border-radius: 2px; + box-sizing: border-box; + justify-content: space-between; + + &.editing { + background-color: #EDEDED; + } +} + +.content { + flex-grow: 1; +} + +.actions { + flex-grow: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.email { + margin: 0; +} + +.username { + margin-bottom: 4px; +} + +.button { + border: 1px solid #787d80; + background-color: transparent; + height: 30px; + font-size: 0.9em; + line-height: normal; +} + +.saveButton { + background-color: #3498DB; + border-color: #3498DB; + color: white; + + > i { + font-size: 17px; + } + + &:hover { + background-color: #399ee2; + color: white; + } + + &:disabled { + border-color: #e0e0e0; + + &:hover { + background-color: #e0e0e0; + color: #4f5c67; + cursor: default; + } + } +} + +.cancelButton { + color:#787D80; + margin-top: 6px; + font-size: 0.9em; + + &:hover { + cursor: pointer; + } +} + +.detailLabel { + border: solid 1px #787D80; + border-radius: 2px; + background-color: white; + height: 30px; + display: inline-block; + width: 230px; + display: flex; + + > .detailLabelIcon { + font-size: 1.2em; + padding: 0 5px; + color: #787D80; + line-height: 30px; + } + + &.disabled { + background-color: #E0E0E0; + } +} + +.detailValue { + background: transparent; + border: none; + font-size: 1em; + color: #000; + height: 30px; + outline: none; + flex: 1; +} + +.bottomText { + color: #474747; + font-size: 0.9em; +} + +.detailList { + list-style: none; + margin: 0; + padding: 0; +} + +.detailItem { + margin-bottom: 12px; +} diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.js b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.js new file mode 100644 index 000000000..8188bdfbe --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsername.js @@ -0,0 +1,188 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './ChangeUsername.css'; +import { Button } from 'plugin-api/beta/client/components/ui'; +import ChangeUsernameDialog from './ChangeUsernameDialog'; +import { t } from 'plugin-api/beta/client/services'; +import InputField from './InputField'; +import { getErrorMessages } from 'coral-framework/utils'; +import { canUsernameBeUpdated } from 'coral-framework/utils/user'; + +const initialState = { + editing: false, + showDialog: false, + formData: {}, +}; + +class ChangeUsername extends React.Component { + state = initialState; + + clearForm = () => { + this.setState(initialState); + }; + + enableEditing = () => { + this.setState({ + editing: true, + }); + }; + + disableEditing = () => { + this.setState({ + editing: false, + }); + }; + + cancel = () => { + this.clearForm(); + this.disableEditing(); + }; + + showDialog = () => { + this.setState({ + showDialog: true, + }); + }; + + onSave = async () => { + this.showDialog(); + }; + + saveChanges = async () => { + const { newUsername } = this.state.formData; + const { changeUsername } = this.props; + + try { + await changeUsername(newUsername); + this.props.notify( + 'success', + t('talk-plugin-auth.change_username.changed_username_success_msg') + ); + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + } + + this.clearForm(); + this.disableEditing(); + }; + + onChange = e => { + const { name, value } = e.target; + + this.setState(state => ({ + formData: { + ...state.formData, + [name]: value, + }, + })); + }; + + closeDialog = () => { + this.setState({ + showDialog: false, + }); + }; + + render() { + const { + username, + emailAddress, + root: { me: { state: { status } } }, + notify, + } = this.props; + const { editing, formData, showDialog } = this.state; + + return ( +
+ + + {editing ? ( +
+
+ + + {t('talk-plugin-auth.change_username.change_username_note')} + + + +
+
+ ) : ( +
+

{username}

+ {emailAddress ? ( +

{emailAddress}

+ ) : null} +
+ )} + {editing ? ( +
+ + + {t('talk-plugin-auth.change_username.cancel')} + +
+ ) : ( +
+ +
+ )} +
+ ); + } +} + +ChangeUsername.propTypes = { + root: PropTypes.object.isRequired, + changeUsername: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + username: PropTypes.string, + emailAddress: PropTypes.string, +}; + +export default ChangeUsername; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.css b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.css new file mode 100644 index 000000000..af681d596 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.css @@ -0,0 +1,84 @@ +.dialog { + border: none; + box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); + width: 320px; + top: 10px; + font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif; + font-size: 14px; + border-radius: 4px; + padding: 12px 20px; +} + +.close { + font-size: 20px; + line-height: 14px; + top: 10px; + right: 10px; + position: absolute; + display: block; + font-weight: bold; + color: #363636; + cursor: pointer; + + &:hover { + color: #6b6b6b; + } +} + +.title { + font-size: 1.3em; + margin-bottom: 8px; +} + +.description { + font-size: 1em; + line-height: 20px; + margin: 0; +} + +.item { + display: block; + color: #4C4C4D; + font-size: 1em; + margin-bottom: 2px; +} + +.bottomNote { + font-size: 0.9em; + line-height: 20px; + padding-top: 10px; + display: block; +} + +.bottomActions { + text-align: right; +} + +.usernamesChange { + margin: 18px 0; +} + +.cancel { + border: 1px solid #787d80; + background-color: transparent; + height: 30px; + font-size: 0.9em; + line-height: normal; + + &:hover { + background-color: #eaeaea; + } +} + +.confirmChanges { + background-color: #3498DB; + border-color: #3498DB; + color: white; + height: 30px; + font-size: 0.9em; + + &:hover { + background-color: #3ba3ec; + color: white; + } +} \ No newline at end of file diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.js b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.js new file mode 100644 index 000000000..321b36926 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangeUsernameDialog.js @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import styles from './ChangeUsernameDialog.css'; +import InputField from './InputField'; +import { Button, Dialog } from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; + +class ChangeUsernameDialog extends React.Component { + state = { + showError: false, + }; + + showError = () => { + this.setState({ + showError: true, + }); + }; + + confirmChanges = async () => { + if (this.formHasError()) { + this.showError(); + return; + } + + if (!this.props.canUsernameBeUpdated) { + this.props.notify( + 'error', + t('talk-plugin-auth.change_username.change_username_attempt') + ); + return; + } + + await this.props.saveChanges(); + this.props.closeDialog(); + }; + + formHasError = () => + this.props.formData.confirmNewUsername !== this.props.formData.newUsername; + + render() { + return ( + + + × + +

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

+
+

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

+
+ + {t('talk-plugin-auth.change_username.old_username')}:{' '} + {this.props.username} + + + {t('talk-plugin-auth.change_username.new_username')}:{' '} + {this.props.formData.newUsername} + +
+
+ + + {t('talk-plugin-auth.change_username.bottom_note')} + + +
+
+ + +
+
+
+ ); + } +} + +ChangeUsernameDialog.propTypes = { + saveChanges: PropTypes.func, + closeDialog: PropTypes.func, + showDialog: PropTypes.bool, + onChange: PropTypes.func, + username: PropTypes.string, + formData: PropTypes.object, + canUsernameBeUpdated: PropTypes.bool.isRequired, + notify: PropTypes.func.isRequired, +}; + +export default ChangeUsernameDialog; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css b/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css index d7a1b6d45..abcf692fe 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ErrorMessage.css @@ -1,6 +1,5 @@ .errorMsg { color: #FA4643; - padding-left: 4px; font-size: 0.9em; } diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/Form.css b/plugins/talk-plugin-auth/client/profile-settings/components/Form.css deleted file mode 100644 index b7a2a21ee..000000000 --- a/plugins/talk-plugin-auth/client/profile-settings/components/Form.css +++ /dev/null @@ -1,5 +0,0 @@ -.detailList { - padding: 0; - margin: 0; - list-style: none; -} \ No newline at end of file diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/Form.js b/plugins/talk-plugin-auth/client/profile-settings/components/Form.js deleted file mode 100644 index 8a455e808..000000000 --- a/plugins/talk-plugin-auth/client/profile-settings/components/Form.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import styles from './Form.css'; -import PropTypes from 'prop-types'; - -const Form = ({ children, className = '' }) => ( -
-
    {children}
-
-); - -Form.propTypes = { - className: PropTypes.string, - children: PropTypes.node, -}; - -export default Form; diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css b/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css index be25a44b9..cd6015e47 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css +++ b/plugins/talk-plugin-auth/client/profile-settings/components/InputField.css @@ -7,8 +7,38 @@ display: flex; } +.columnDisplay { + flex-direction: column; + + .detailItemMessage { + padding: 4px 0 0; + } +} + .detailItemContent { - min-width: 280px; + border: solid 1px #787D80; + border-radius: 2px; + background-color: white; + height: 30px; + display: inline-block; + width: 230px; + display: flex; + box-sizing: border-box; + + > .detailIcon { + font-size: 1.2em; + padding: 0 5px; + color: #787D80; + line-height: 30px; + } + + &.error { + border: solid 2px #FA4643; + } + + &.disabled { + background-color: #E0E0E0; + } } .detailLabel { @@ -19,22 +49,21 @@ } .detailValue { - padding: 6px 2px; - border: solid 1px #979797; - display: block; - font-size: 1.1em; - border-radius: 2px; - background-color: #ffffff; - color: #979797; + background: transparent; + border: none; + font-size: 1em; + color: #000; + outline: none; + flex: 1; + height: 100%; box-sizing: border-box; - width: 100%; } .detailItemMessage { flex-grow: 1; display: flex; align-items: center; - padding-left: 2px; + padding-left: 6px; padding-top: 16px; .warningIcon, .checkIcon { @@ -48,4 +77,4 @@ .warningIcon { color: #FA4643; -} +} \ No newline at end of file diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js b/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js index ed9c394ee..34c314c20 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js +++ b/plugins/talk-plugin-auth/client/profile-settings/components/InputField.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import cn from 'classnames'; import styles from './InputField.css'; import ErrorMessage from './ErrorMessage'; import { Icon } from 'plugin-api/beta/client/components/ui'; @@ -10,51 +11,84 @@ const InputField = ({ type = 'text', name = '', onChange = () => {}, - value = '', showError = true, hasError = false, errorMsg = '', children, + columnDisplay = false, + showSuccess = false, + validationType = '', + icon = '', + value = '', + defaultValue = '', + disabled = false, }) => { + const inputValue = { + ...(value ? { value } : {}), + ...(defaultValue ? { defaultValue } : {}), + }; + return ( -
  • -
    -
    +
    +
    + {label && ( + )} +
    + {icon && }
    {!hasError && + showSuccess && value && } {hasError && showError && {errorMsg}}
    {children} -
  • +
    ); }; InputField.propTypes = { - id: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, + id: PropTypes.string, + disabled: PropTypes.bool, + label: PropTypes.string, + type: PropTypes.string, name: PropTypes.string.isRequired, onChange: PropTypes.func, value: PropTypes.string, + defaultValue: PropTypes.string, + icon: PropTypes.string, showError: PropTypes.bool, hasError: PropTypes.bool, errorMsg: PropTypes.string, children: PropTypes.node, + columnDisplay: PropTypes.bool, + showSuccess: PropTypes.bool, + validationType: PropTypes.string, }; export default InputField; diff --git a/plugins/talk-plugin-auth/client/profile-settings/containers/ChangeUsername.js b/plugins/talk-plugin-auth/client/profile-settings/containers/ChangeUsername.js new file mode 100644 index 000000000..87e1e18b5 --- /dev/null +++ b/plugins/talk-plugin-auth/client/profile-settings/containers/ChangeUsername.js @@ -0,0 +1,12 @@ +import { compose } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { connect } from 'plugin-api/beta/client/hocs'; +import ChangeUsername from '../components/ChangeUsername'; +import { notify } from 'coral-framework/actions/notification'; +import { withChangeUsername } from 'plugin-api/beta/client/hocs'; + +const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch); + +export default compose(connect(null, mapDispatchToProps), withChangeUsername)( + ChangeUsername +); diff --git a/plugins/talk-plugin-auth/client/translations.yml b/plugins/talk-plugin-auth/client/translations.yml index 915aee1c7..7cc0d9267 100644 --- a/plugins/talk-plugin-auth/client/translations.yml +++ b/plugins/talk-plugin-auth/client/translations.yml @@ -58,8 +58,9 @@ da: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" + password_error: "Password must be at least 8 characters." forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" register: "Register" @@ -101,8 +102,9 @@ en: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" + password_error: "Password must be at least 8 characters." forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" register: "Register" @@ -140,6 +142,20 @@ en: cancel: "Cancel" edit: "Edit" changed_password_msg: "Changed Password - Your password has been successfully changed" + change_username: + change_username_note: "Usernames can be changed every 14 days" + save: "Save" + edit_profile: "Edit Profile" + cancel: "Cancel" + confirm_username_change: "Confirm Username Change" + description: "You are attempting to change your username. Your new username will appear on all of your past and future comments." + old_username: "Old Username" + new_username: "New Username" + bottom_note: "Note: You will not be able to change your username again for 14 days" + confirm_changes: "Confirm Changes" + username_does_not_match: "Username does not match" + changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days." + change_username_attempt: "Username can't be updated. Usernames can be changed every 14 days" de: talk-plugin-auth: login: @@ -201,6 +217,7 @@ es: or: "O" email: "Dirección de Correo" password: "Contraseña" + password_error: "La contraseña debe tener al menos 8 caracteres." forgot_your_pass: "¿Has olvidado tu contraseña?" need_an_account: "¿Necesitas una cuenta?" register: "Registrar" @@ -240,6 +257,20 @@ es: cancel: "Cancelar" edit: "Editar" changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada" + change_username: + change_username_note: "El usuario puede ser cambiado cada 14 días" + save: "Guardar" + edit_profile: "Editar Perfil" + cancel: "Cancelar" + confirm_username_change: "Confirmar Cambio de Usuario" + description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios." + old_username: "Usuario viejo" + new_username: "Usuario nuevo" + bottom_note: "Nota: No podrás cambiar tu usuario por 14 días" + confirm_changes: "Confirmar Cambios" + username_does_not_match: "El usuario no coincide" + changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días." + change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días." fr: talk-plugin-auth: login: @@ -342,7 +373,7 @@ pt_BR: sign_in: "Sign in" sign_in_to_join: "Sign in to join the conversation" or: "Or" - email: "E-mail Address" + email: "Email Address" password: "Password" forgot_your_pass: "Forgot your password?" need_an_account: "Need an account?" diff --git a/plugins/talk-plugin-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 bc642f93f..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, }); } 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 657be737a..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, @@ -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; + } } /** 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); + }); + }); + }); + } }); });