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/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/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/profile-settings/components/ChangePassword.css b/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.css index e94247ac1..6c7f9ae41 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.css +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.css @@ -47,7 +47,7 @@ border: 1px solid #787d80; background-color: transparent; height: 30px; - font-size: 1em; + font-size: 0.9em; line-height: normal; } diff --git a/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.js b/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.js index 3479b5dd9..90940728d 100644 --- a/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.js +++ b/plugins/talk-plugin-auth/client/profile-settings/components/ChangePassword.js @@ -7,7 +7,6 @@ import validate from 'coral-framework/helpers/validate'; import errorMsj from 'coral-framework/helpers/error'; import isEqual from 'lodash/isEqual'; import { t } from 'plugin-api/beta/client/services'; -import Form from './Form'; import InputField from './InputField'; import { getErrorMessages } from 'coral-framework/utils'; @@ -148,7 +147,7 @@ class ChangePassword extends React.Component { {t('talk-plugin-auth.change_password.change_password')} {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 e774c916b..7cc0d9267 100644 --- a/plugins/talk-plugin-auth/client/translations.yml +++ b/plugins/talk-plugin-auth/client/translations.yml @@ -142,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: @@ -243,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: 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); + }); + }); + }); + } }); });