diff --git a/public/language/en-GB/admin/manage/users.json b/public/language/en-GB/admin/manage/users.json index 1fef96f56d..08e5701f40 100644 --- a/public/language/en-GB/admin/manage/users.json +++ b/public/language/en-GB/admin/manage/users.json @@ -4,16 +4,21 @@ "edit": "Actions", "make-admin": "Make Admin", "remove-admin": "Remove Admin", + "change-email": "Change Email", + "new-email": "New Email", "validate-email": "Validate Email", "send-validation-email": "Send Validation Email", + "change-password": "Change Password", "password-reset-email": "Send Password Reset Email", "force-password-reset": "Force Password Reset & Log User Out", - "ban": "Ban User(s)", + "ban": "Ban", + "ban-users": "Ban User(s)", "temp-ban": "Ban User(s) Temporarily", "unban": "Unban User(s)", "reset-lockout": "Reset Lockout", "reset-flags": "Reset Flags", - "delete": "Delete User(s)", + "delete": "Delete", + "delete-users": "Delete User(s)", "delete-content": "Delete User(s) Content", "purge": "Delete User(s) and Content", "download-csv": "Download CSV", @@ -81,6 +86,7 @@ "alerts.button-ban-x": "Ban %1 user(s)", "alerts.unban-success": "User(s) unbanned!", "alerts.lockout-reset-success": "Lockout(s) reset!", + "alerts.password-change-success": "Password(s) changed!", "alerts.flag-reset-success": "Flags(s) reset!", "alerts.no-remove-yourself-admin": "You can't remove yourself as Administrator!", "alerts.make-admin-success": "User is now administrator.", @@ -106,6 +112,7 @@ "alerts.create": "Create User", "alerts.button-create": "Create", "alerts.button-cancel": "Cancel", + "alerts.button-change": "Change", "alerts.error-passwords-different": "Passwords must match!", "alerts.error-x": "Error

%1

", "alerts.create-success": "User created!", @@ -113,6 +120,10 @@ "alerts.prompt-email": "Emails: ", "alerts.email-sent-to": "An invitation email has been sent to %1", "alerts.x-users-found": "%1 user(s) found, (%2 seconds)", + "alerts.select-a-single-user-to-change-email": "Select a single user to change email", "export-users-started": "Exporting users as csv, this might take a while. You will receive a notification when it is complete.", - "export-users-completed": "Users exported as csv, click here to download." + "export-users-completed": "Users exported as csv, click here to download.", + "email": "Email", + "password": "Password", + "manage": "Manage" } \ No newline at end of file diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index 69bb631da7..e56cdfd4c1 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -64,6 +64,7 @@ "change_picture": "Change Picture", "change_username": "Change Username", "change_email": "Change Email", + "email-updated": "Email Updated", "email_same_as_password": "Please enter your current password to continue – you've entered your new email again", "edit": "Edit", "edit-profile": "Edit Profile", diff --git a/public/openapi/write/users/uid/emails.yaml b/public/openapi/write/users/uid/emails.yaml index c6b67acf9e..3c046a0dee 100644 --- a/public/openapi/write/users/uid/emails.yaml +++ b/public/openapi/write/users/uid/emails.yaml @@ -81,4 +81,6 @@ post: type: array items: type: string - description: An email address \ No newline at end of file + description: An email address + '400': + description: error occured (aka email taken) diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js index 613389d9f9..d2ac9d20c1 100644 --- a/public/src/admin/manage/users.js +++ b/public/src/admin/manage/users.js @@ -1,8 +1,8 @@ 'use strict'; define('admin/manage/users', [ - 'translator', 'benchpress', 'autocomplete', 'api', 'slugify', 'bootbox', 'alerts', 'accounts/invite', 'helpers', -], function (translator, Benchpress, autocomplete, api, slugify, bootbox, alerts, AccountInvite, helpers) { + 'translator', 'benchpress', 'autocomplete', 'api', 'slugify', 'bootbox', 'alerts', 'accounts/invite', 'helpers', 'admin/modules/change-email', +], function (translator, Benchpress, autocomplete, api, slugify, bootbox, alerts, AccountInvite, helpers, changeEmail) { const Users = {}; Users.init = function () { @@ -273,6 +273,26 @@ define('admin/manage/users', [ socket.emit('admin.user.resetLockouts', uids, done('[[admin/manage/users:alerts.lockout-reset-success]]')); }); + $('.change-email').on('click', function () { + const uids = getSelectedUids(); + if (uids.length !== 1) { + return alerts.error('[[admin/manage/users:alerts.select-a-single-user-to-change-email]]'); + } + changeEmail.init({ + uid: uids[0], + onSuccess: function (newEmail) { + update('.notvalidated', false); + update('.pending', false); + update('.expired', false); + update('.validated', false); + update('.validated-by-admin', !!newEmail); + update('.no-email', !newEmail); + $('.users-table [component="user/select/single"]:checked').parents('.user-row').find('.validated-by-admin .email').text(newEmail); + // $('.users-table [component="user/select/single"]:checked').parents('.user-row').find('.no-email'). + }, + }); + }); + $('.validate-email').on('click', function () { const uids = getSelectedUids(); if (!uids.length) { @@ -311,6 +331,51 @@ define('admin/manage/users', [ }); }); + $('.change-password').on('click', async function () { + const uids = getSelectedUids(); + if (!uids.length) { + return; + } + async function changePassword(modal) { + const newPassword = modal.find('#newPassword').val(); + const confirmPassword = modal.find('#confirmPassword').val(); + if (newPassword !== confirmPassword) { + throw new Error('[[[user:change_password_error_match]]'); + } + await Promise.all(uids.map(uid => api.put('/users/' + uid + '/password', { + currentPassword: '', + newPassword: newPassword, + }))); + } + + const modal = bootbox.dialog({ + message: `
+ + + + +
`, + title: '[[admin/manage/users:change-password]]', + onEscape: true, + buttons: { + cancel: { + label: '[[admin/manage/users:alerts.button-cancel]]', + className: 'btn-link', + }, + change: { + label: '[[admin/manage/users:alerts.button-change]]', + className: 'btn-primary', + callback: function () { + changePassword(modal).then(() => { + modal.modal('hide'); + }).catch(alerts.error); + return false; + }, + }, + }, + }); + }); + $('.password-reset-email').on('click', function () { const uids = getSelectedUids(); if (!uids.length) { diff --git a/public/src/admin/modules/change-email.js b/public/src/admin/modules/change-email.js new file mode 100644 index 0000000000..f237792854 --- /dev/null +++ b/public/src/admin/modules/change-email.js @@ -0,0 +1,41 @@ +'use strict'; + +define('admin/modules/change-email', [ + 'api', 'bootbox', 'alerts', +], function (api, bootbox, alerts) { + const ChangeEmail = {}; + + ChangeEmail.init = function (params) { + const modal = bootbox.dialog({ + message: ` + + + `, + title: '[[admin/manage/users:change-email]]', + onEscape: true, + buttons: { + cancel: { + label: '[[admin/manage/users:alerts.button-cancel]]', + className: 'btn-link', + }, + change: { + label: '[[admin/manage/users:alerts.button-change]]', + className: 'btn-primary', + callback: function () { + const newEmail = modal.find('#newEmail').val(); + api.post('/users/' + params.uid + '/emails', { + skipConfirmation: true, + email: newEmail, + }).then(() => { + modal.modal('hide'); + params.onSuccess(newEmail); + }).catch(alerts.error); + return false; + }, + }, + }, + }); + }; + + return ChangeEmail; +}); diff --git a/public/src/client/account/edit.js b/public/src/client/account/edit.js index 929767bb4a..587b4055ab 100644 --- a/public/src/client/account/edit.js +++ b/public/src/client/account/edit.js @@ -8,7 +8,8 @@ define('forum/account/edit', [ 'hooks', 'bootbox', 'alerts', -], function (header, picture, translator, api, hooks, bootbox, alerts) { + 'admin/modules/change-email', +], function (header, picture, translator, api, hooks, bootbox, alerts, changeEmail) { const AccountEdit = {}; AccountEdit.init = function () { @@ -25,6 +26,19 @@ define('forum/account/edit', [ updateSignature(); updateAboutMe(); handleGroupSort(); + + if (!ajaxify.data.isSelf && app.user.isAdmin) { + $(`a[href="${config.relative_path}/user/${ajaxify.data.userslug}/edit/email"]`).on('click', () => { + changeEmail.init({ + uid: ajaxify.data.uid, + email: ajaxify.data.email, + onSuccess: function () { + alerts.success('[[user:email-updated]]'); + }, + }); + return false; + }); + } }; function updateProfile() { diff --git a/public/src/client/account/profile.js b/public/src/client/account/profile.js index 03a91066e1..dc2885e80e 100644 --- a/public/src/client/account/profile.js +++ b/public/src/client/account/profile.js @@ -15,7 +15,10 @@ define('forum/account/profile', [ processPage(); if (parseInt(ajaxify.data.emailChanged, 10) === 1) { - bootbox.alert('[[user:emailUpdate.change-instructions]]'); + bootbox.alert({ + message: '[[user:emailUpdate.change-instructions]]', + closeButton: false, + }); } socket.removeListener('event:user_status_change', onUserStatusChange); diff --git a/src/api/users.js b/src/api/users.js index 84fe4b2b3b..d4456128d1 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -417,8 +417,15 @@ usersAPI.addEmail = async (caller, { email, skipConfirmation, uid }) => { skipConfirmation = canManageUsers && skipConfirmation; if (skipConfirmation) { - await user.setUserField(uid, 'email', email); - await user.email.confirmByUid(uid); + if (!email.length) { + await user.email.remove(uid); + } else { + if (!await user.email.available(email)) { + throw new Error('[[error:email-taken]]'); + } + await user.setUserField(uid, 'email', email); + await user.email.confirmByUid(uid); + } } else { await usersAPI.update(caller, { uid, email }); } diff --git a/src/controllers/accounts/edit.js b/src/controllers/accounts/edit.js index 8560f043e8..f3b8ce58ec 100644 --- a/src/controllers/accounts/edit.js +++ b/src/controllers/accounts/edit.js @@ -90,16 +90,7 @@ editController.username = async function (req, res, next) { editController.email = async function (req, res, next) { const targetUid = await user.getUidByUserslug(req.params.userslug); - if (!targetUid) { - return next(); - } - - const [isAdminOrGlobalMod, canEdit] = await Promise.all([ - user.isAdminOrGlobalMod(req.uid), - privileges.users.canEdit(req.uid, targetUid), - ]); - - if (!isAdminOrGlobalMod && !canEdit) { + if (!targetUid || req.uid !== parseInt(targetUid, 10)) { return next(); } diff --git a/src/user/email.js b/src/user/email.js index c5d025cb15..9a18a62e34 100644 --- a/src/user/email.js +++ b/src/user/email.js @@ -40,7 +40,12 @@ UserEmail.remove = async function (uid, sessionId) { db.sortedSetRemove('email:sorted', `${email.toLowerCase()}:${uid}`), user.email.expireValidation(uid), sessionId ? user.auth.revokeAllSessions(uid, sessionId) : Promise.resolve(), - events.log({ type: 'email-change', email, newEmail: '' }), + events.log({ + targetUid: uid, + type: 'email-change', + email, + newEmail: '', + }), ]); }; diff --git a/src/user/interstitials.js b/src/user/interstitials.js index fbdb63f9ab..c16cb48ff0 100644 --- a/src/user/interstitials.js +++ b/src/user/interstitials.js @@ -28,8 +28,7 @@ Interstitials.email = async (data) => { return data; } - const [canManageUsers, hasPassword, hasPending] = await Promise.all([ - privileges.admin.can('admin:users', data.req.uid), + const [hasPassword, hasPending] = await Promise.all([ user.hasPassword(data.userData.uid), user.email.isValidationPending(data.userData.uid), ]); @@ -44,12 +43,7 @@ Interstitials.email = async (data) => { data: { email, requireEmailAddress: meta.config.requireEmailAddress, - issuePasswordChallenge: - hasPassword && - ( - (canManageUsers && data.userData.uid === data.req.uid) || // admin changing own email - (!canManageUsers && !!data.userData.uid) // non-admins changing own email - ), + issuePasswordChallenge: hasPassword, hasPending, }, callback: async (userData, formData) => { @@ -73,7 +67,7 @@ Interstitials.email = async (data) => { }), ]); - if (!canManageUsers && !isPasswordCorrect) { + if (!isPasswordCorrect) { await sleep(2000); } @@ -92,14 +86,7 @@ Interstitials.email = async (data) => { } // Admins editing will auto-confirm, unless editing their own email - if (canManageUsers && userData.uid !== data.req.uid) { - if (!await user.email.available(formData.email)) { - throw new Error('[[error:email-taken]]'); - } - await user.email.remove(userData.uid); - await user.setUserField(userData.uid, 'email', formData.email); - await user.email.confirmByUid(userData.uid); - } else if (canEdit) { + if (canEdit) { if (hasPassword && !isPasswordCorrect) { throw new Error('[[error:invalid-password]]'); } @@ -120,8 +107,8 @@ Interstitials.email = async (data) => { throw new Error('[[error:invalid-email]]'); } - if (current.length && (!hasPassword || (hasPassword && isPasswordCorrect) || canManageUsers)) { - // User or admin explicitly clearing their email + if (current.length && (!hasPassword || (hasPassword && isPasswordCorrect))) { + // User explicitly clearing their email await user.email.remove(userData.uid, isSelf ? data.req.session.id : null); } } diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index 23127a9876..5c76ac94cc 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -39,22 +39,40 @@
-
@@ -105,36 +123,34 @@ {users.username} - +
- {{{ if (!./email && !./emailToConfirm) }}} - [[admin/manage/users:users.no-email]] - {{{ else }}} + [[admin/manage/users:users.no-email]] + - {{{ if ./email }}}{./email}{{{ end }}} + - + - {./emailToConfirm} + - + - {./emailToConfirm} + - + - {./emailToConfirm} + - {{{ end }}}
diff --git a/test/api.js b/test/api.js index 6723cd3f5e..7abfec005e 100644 --- a/test/api.js +++ b/test/api.js @@ -547,6 +547,11 @@ describe('API', async () => { return; } + if (response.statusCode === 400 && context[method].responses['400']) { + // TODO: check 400 schema to response.body? + return; + } + const http200 = context[method].responses['200']; if (!http200) { return; diff --git a/test/controllers.js b/test/controllers.js index 70e3b8a80a..87e7a460d7 100644 --- a/test/controllers.js +++ b/test/controllers.js @@ -455,22 +455,6 @@ describe('Controllers', () => { assert.strictEqual(result.req.session.emailChanged, 1); }); - it('should set email if admin is changing it', async () => { - const uid = await user.create({ username: 'interstiuser3' }); - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: adminUid }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid: uid }, { - email: 'interstiuser3@nodebb.org', - }); - const userData = await user.getUserData(uid); - assert.strictEqual(userData.email, 'interstiuser3@nodebb.org'); - assert.strictEqual(userData['email:confirmed'], 1); - }); - it('should throw error if user tries to edit other users email', async () => { const uid = await user.create({ username: 'interstiuser4' }); try {