+
+
+ {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);
+ });
+ });
+ });
+ }
});
});