From 50334ad51f37b363d8c315ac247ca8245c188be4 Mon Sep 17 00:00:00 2001 From: pierre-lehnen-rc <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Thu, 4 Jun 2020 19:00:30 -0300 Subject: [PATCH] [NEW] Assign oldest active user as owner when deleting last room owner (#16088) Co-authored-by: Diego Sampaio --- app/api/server/v1/users.js | 6 +- app/authorization/server/functions/hasRole.js | 2 + app/authorization/server/index.js | 3 +- app/lib/server/functions/deleteUser.js | 77 ++---------- .../functions/getRoomsWithSingleOwner.js | 60 +++++++++ .../functions/getUserSingleOwnedRooms.js | 25 ++++ app/lib/server/functions/index.js | 2 + .../functions/relinquishRoomOwnerships.js | 30 +++++ .../server/functions/setUserActiveStatus.js | 18 ++- .../server/methods/deleteUserOwnAccount.js | 6 +- app/models/server/models/Subscriptions.js | 9 ++ app/models/server/models/Users.js | 9 ++ app/ui-account/client/accountProfile.js | 55 ++++---- app/ui-flextab/client/tabs/userActions.js | 10 +- app/ui-utils/client/index.js | 1 + .../lib/warnUserDeletionMayRemoveRooms.js | 63 ++++++++++ client/admin/users/UserInfoActions.js | 119 ++++++++++++++++-- packages/rocketchat-i18n/i18n/en.i18n.json | 7 ++ server/methods/deleteUser.js | 6 +- server/methods/setUserActiveStatus.js | 4 +- 20 files changed, 396 insertions(+), 116 deletions(-) create mode 100644 app/lib/server/functions/getRoomsWithSingleOwner.js create mode 100644 app/lib/server/functions/getUserSingleOwnedRooms.js create mode 100644 app/lib/server/functions/relinquishRoomOwnerships.js create mode 100644 app/ui-utils/client/lib/warnUserDeletionMayRemoveRooms.js diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 65aee384de9de..c252b743e8491 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -76,9 +76,10 @@ API.v1.addRoute('users.delete', { authRequired: true }, { } const user = this.getUserFromParams(); + const { confirmRelinquish = false } = this.requestParams(); Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUser', user._id); + Meteor.call('deleteUser', user._id, confirmRelinquish); }); return API.v1.success(); @@ -122,6 +123,7 @@ API.v1.addRoute('users.setActiveStatus', { authRequired: true }, { check(this.bodyParams, { userId: String, activeStatus: Boolean, + confirmRelinquish: Match.Maybe(Boolean), }); if (!hasPermission(this.userId, 'edit-other-user-active-status')) { @@ -129,7 +131,7 @@ API.v1.addRoute('users.setActiveStatus', { authRequired: true }, { } Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', this.bodyParams.userId, this.bodyParams.activeStatus); + Meteor.call('setUserActiveStatus', this.bodyParams.userId, this.bodyParams.activeStatus, this.bodyParams.confirmRelinquish); }); return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields: { active: 1 } }) }); }, diff --git a/app/authorization/server/functions/hasRole.js b/app/authorization/server/functions/hasRole.js index 0159026d46fed..e5d8927cbb747 100644 --- a/app/authorization/server/functions/hasRole.js +++ b/app/authorization/server/functions/hasRole.js @@ -6,3 +6,5 @@ export const hasRoleAsync = async (userId, roleNames, scope) => { }; export const hasRole = (userId, roleNames, scope) => Promise.await(hasRoleAsync(userId, roleNames, scope)); + +export const subscriptionHasRole = (sub, role) => sub.roles && sub.roles.includes(role); diff --git a/app/authorization/server/index.js b/app/authorization/server/index.js index 0ebc74f0ce772..485038ae3e233 100644 --- a/app/authorization/server/index.js +++ b/app/authorization/server/index.js @@ -12,7 +12,7 @@ import { hasAtLeastOnePermission, hasPermission, } from './functions/hasPermission'; -import { hasRole } from './functions/hasRole'; +import { hasRole, subscriptionHasRole } from './functions/hasRole'; import { removeUserFromRoles } from './functions/removeUserFromRoles'; import { AuthorizationUtils } from '../lib/AuthorizationUtils'; import './methods/addPermissionToRole'; @@ -28,6 +28,7 @@ export { getRoles, getUsersInRole, hasRole, + subscriptionHasRole, removeUserFromRoles, canSendMessage, addRoomAccessValidator, diff --git a/app/lib/server/functions/deleteUser.js b/app/lib/server/functions/deleteUser.js index ef39206c74bd4..6f4f4b16f5e0c 100644 --- a/app/lib/server/functions/deleteUser.js +++ b/app/lib/server/functions/deleteUser.js @@ -3,23 +3,14 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { FileUpload } from '../../../file-upload/server'; import { Users, Subscriptions, Messages, Rooms, Integrations, FederationServers } from '../../../models/server'; -import { hasRole, getUsersInRole } from '../../../authorization/server'; import { settings } from '../../../settings/server'; import { Notifications } from '../../../notifications/server'; import { updateGroupDMsName } from './updateGroupDMsName'; +import { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; +import { getSubscribedRoomsForUserWithDetails, shouldRemoveOrChangeOwner } from './getRoomsWithSingleOwner'; +import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; -const bulkRoomCleanUp = (rids) => { - // no bulk deletion for files - rids.forEach((rid) => FileUpload.removeFilesByRoomId(rid)); - - return Promise.await(Promise.all([ - Subscriptions.removeByRoomIds(rids), - Messages.removeByRoomIds(rids), - Rooms.removeByIds(rids), - ])); -}; - -export const deleteUser = function(userId) { +export const deleteUser = function(userId, confirmRelinquish = false) { const user = Users.findOneById(userId, { fields: { username: 1, avatarOrigin: 1, federation: 1 }, }); @@ -36,47 +27,16 @@ export const deleteUser = function(userId) { } } + const subscribedRooms = getSubscribedRoomsForUserWithDetails(userId); + + if (shouldRemoveOrChangeOwner(subscribedRooms) && !confirmRelinquish) { + const rooms = getUserSingleOwnedRooms(subscribedRooms); + throw new Meteor.Error('user-last-owner', '', rooms); + } + // Users without username can't do anything, so there is nothing to remove if (user.username != null) { - const roomCache = []; - - // Iterate through all the rooms the user is subscribed to, to check if they are the last owner of any of them. - Subscriptions.db.findByUserId(userId).forEach((subscription) => { - const roomData = { - rid: subscription.rid, - t: subscription.t, - subscribers: null, - }; - - // DMs can always be deleted, so let's ignore it on this check - if (roomData.t !== 'd') { - // If the user is an owner on this room - if (hasRole(user._id, 'owner', subscription.rid)) { - // Fetch the number of owners - const numOwners = getUsersInRole('owner', subscription.rid).fetch().length; - // If it's only one, then this user is the only owner. - if (numOwners === 1) { - // If the user is the last owner of a public channel, then we need to abort the deletion - if (roomData.t === 'c') { - throw new Meteor.Error('error-user-is-last-owner', `To delete this user you'll need to set a new owner to the following room: ${ subscription.name }.`, { - method: 'deleteUser', - }); - } - - // For private groups, let's check how many subscribers it has. If the user is the only subscriber, then it will be eliminated and doesn't need to abort the deletion - roomData.subscribers = Subscriptions.findByRoomId(subscription.rid).count(); - - if (roomData.subscribers > 1) { - throw new Meteor.Error('error-user-is-last-owner', `To delete this user you'll need to set a new owner to the following room: ${ subscription.name }.`, { - method: 'deleteUser', - }); - } - } - } - } - - roomCache.push(roomData); - }); + relinquishRoomOwnerships(userId, subscribedRooms); const messageErasureType = settings.get('Message_ErasureType'); switch (messageErasureType) { @@ -94,19 +54,6 @@ export const deleteUser = function(userId) { break; } - const roomIds = roomCache.filter((roomData) => { - if (roomData.subscribers === null && roomData.t !== 'd' && roomData.t !== 'c') { - roomData.subscribers = Subscriptions.findByRoomId(roomData.rid).count(); - } - - // Remove non-channel rooms with only 1 user (the one being deleted) - return roomData.t !== 'c' && roomData.subscribers === 1; - }).map(({ rid }) => rid); - - Rooms.find1On1ByUserId(user._id, { fields: { _id: 1 } }).forEach(({ _id }) => roomIds.push(_id)); - - bulkRoomCleanUp(roomIds); - Rooms.updateGroupDMsRemovingUsernamesByUsername(user.username); // Remove direct rooms with the user Rooms.removeDirectRoomContainingUsername(user.username); // Remove direct rooms with the user diff --git a/app/lib/server/functions/getRoomsWithSingleOwner.js b/app/lib/server/functions/getRoomsWithSingleOwner.js new file mode 100644 index 0000000000000..b966de6f95861 --- /dev/null +++ b/app/lib/server/functions/getRoomsWithSingleOwner.js @@ -0,0 +1,60 @@ +import { subscriptionHasRole } from '../../../authorization/server'; +import { Users, Subscriptions } from '../../../models/server'; + +export function shouldRemoveOrChangeOwner(subscribedRooms) { + return subscribedRooms + .some(({ shouldBeRemoved, shouldChangeOwner }) => shouldBeRemoved || shouldChangeOwner); +} + +export function getSubscribedRoomsForUserWithDetails(userId) { + const subscribedRooms = []; + + // Iterate through all the rooms the user is subscribed to, to check if he is the last owner of any of them. + Subscriptions.findByUserIdExceptType(userId, 'd').forEach((subscription) => { + const roomData = { + rid: subscription.rid, + t: subscription.t, + shouldBeRemoved: false, + shouldChangeOwner: false, + newOwner: null, + }; + + if (subscriptionHasRole(subscription, 'owner')) { + // Fetch the number of owners + const numOwners = Subscriptions.findByRoomIdAndRoles(subscription.rid, ['owner']).count(); + + // If it's only one, then this user is the only owner. + if (numOwners === 1) { + // Let's check how many subscribers the room has. + const options = { fields: { 'u._id': 1 }, sort: { ts: 1 } }; + const subscribersCursor = Subscriptions.findByRoomId(subscription.rid, options); + + subscribersCursor.forEach(({ u: { _id: uid } }) => { + // If we already changed the owner or this subscription is for the user we are removing, then don't try to give it ownership + if (roomData.shouldChangeOwner || uid === userId) { + return; + } + const newOwner = Users.findOneActiveById(uid, { fields: { _id: 1 } }); + if (!newOwner) { + return; + } + + roomData.newOwner = uid; + roomData.shouldChangeOwner = true; + }); + + // If there's no subscriber available to be the new owner and it's not a public room, we can remove it. + if (!roomData.shouldChangeOwner && roomData.t !== 'c') { + roomData.shouldBeRemoved = true; + } + } + } else if (roomData.t !== 'c') { + // If the user is not an owner, remove the room if the user is the only subscriber + roomData.shouldBeRemoved = Subscriptions.findByRoomId(roomData.rid).count() === 1; + } + + subscribedRooms.push(roomData); + }); + + return subscribedRooms; +} diff --git a/app/lib/server/functions/getUserSingleOwnedRooms.js b/app/lib/server/functions/getUserSingleOwnedRooms.js new file mode 100644 index 0000000000000..45d7b4824f852 --- /dev/null +++ b/app/lib/server/functions/getUserSingleOwnedRooms.js @@ -0,0 +1,25 @@ +import { Rooms } from '../../../models/server'; + +export const getUserSingleOwnedRooms = function(subscribedRooms) { + const roomsThatWillChangeOwner = subscribedRooms.filter(({ shouldChangeOwner }) => shouldChangeOwner).map(({ rid }) => rid); + const roomsThatWillBeRemoved = subscribedRooms.filter(({ shouldBeRemoved }) => shouldBeRemoved).map(({ rid }) => rid); + + const roomIds = roomsThatWillBeRemoved.concat(roomsThatWillChangeOwner); + const rooms = Rooms.findByIds(roomIds, { fields: { _id: 1, name: 1, fname: 1 } }); + + const result = { + shouldBeRemoved: [], + shouldChangeOwner: [], + }; + + rooms.forEach((room) => { + const name = room.fname || room.name; + if (roomsThatWillBeRemoved.includes(room._id)) { + result.shouldBeRemoved.push(name); + } else { + result.shouldChangeOwner.push(name); + } + }); + + return result; +}; diff --git a/app/lib/server/functions/index.js b/app/lib/server/functions/index.js index 7685736716489..ecb2881083d2e 100644 --- a/app/lib/server/functions/index.js +++ b/app/lib/server/functions/index.js @@ -12,12 +12,14 @@ export { deleteRoom } from './deleteRoom'; export { deleteUser } from './deleteUser'; export { getFullUserData } from './getFullUserData'; export { getRoomByNameOrIdWithOptionToJoin } from './getRoomByNameOrIdWithOptionToJoin'; +export { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; export { generateUsernameSuggestion } from './getUsernameSuggestion'; export { insertMessage } from './insertMessage'; export { isTheLastMessage } from './isTheLastMessage'; export { loadMessageHistory } from './loadMessageHistory'; export { processWebhookMessage } from './processWebhookMessage'; export { removeUserFromRoom } from './removeUserFromRoom'; +export { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; export { saveCustomFields } from './saveCustomFields'; export { saveCustomFieldsWithoutValidation } from './saveCustomFieldsWithoutValidation'; export { saveUser } from './saveUser'; diff --git a/app/lib/server/functions/relinquishRoomOwnerships.js b/app/lib/server/functions/relinquishRoomOwnerships.js new file mode 100644 index 0000000000000..7c56e3bc05a52 --- /dev/null +++ b/app/lib/server/functions/relinquishRoomOwnerships.js @@ -0,0 +1,30 @@ +import { FileUpload } from '../../../file-upload/server'; +import { Subscriptions, Messages, Rooms, Roles } from '../../../models/server'; + +const bulkRoomCleanUp = (rids) => { + // no bulk deletion for files + rids.forEach((rid) => FileUpload.removeFilesByRoomId(rid)); + + return Promise.await(Promise.all([ + Subscriptions.removeByRoomIds(rids), + Messages.removeByRoomIds(rids), + Rooms.removeByIds(rids), + ])); +}; + +export const relinquishRoomOwnerships = function(userId, subscribedRooms, removeDirectMessages = true) { + // change owners + subscribedRooms + .filter(({ shouldChangeOwner }) => shouldChangeOwner) + .forEach(({ newOwner, rid }) => Roles.addUserRoles(newOwner, ['owner'], rid)); + + const roomIdsToRemove = subscribedRooms.filter(({ shouldBeRemoved }) => shouldBeRemoved).map(({ rid }) => rid); + + if (removeDirectMessages) { + Rooms.find1On1ByUserId(userId, { fields: { _id: 1 } }).forEach(({ _id }) => roomIdsToRemove.push(_id)); + } + + bulkRoomCleanUp(roomIdsToRemove); + + return subscribedRooms; +}; diff --git a/app/lib/server/functions/setUserActiveStatus.js b/app/lib/server/functions/setUserActiveStatus.js index 5a432d8cb769d..c85651250715f 100644 --- a/app/lib/server/functions/setUserActiveStatus.js +++ b/app/lib/server/functions/setUserActiveStatus.js @@ -1,11 +1,15 @@ +import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { Accounts } from 'meteor/accounts-base'; import * as Mailer from '../../../mailer'; import { Users, Subscriptions } from '../../../models'; import { settings } from '../../../settings'; +import { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; +import { shouldRemoveOrChangeOwner, getSubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; +import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; -export function setUserActiveStatus(userId, active) { +export function setUserActiveStatus(userId, active, confirmRelinquish = false) { check(userId, String); check(active, Boolean); @@ -15,6 +19,18 @@ export function setUserActiveStatus(userId, active) { return false; } + // Users without username can't do anything, so there is no need to check for owned rooms + if (user.username != null && !active) { + const subscribedRooms = getSubscribedRoomsForUserWithDetails(userId); + + if (shouldRemoveOrChangeOwner(subscribedRooms) && !confirmRelinquish) { + const rooms = getUserSingleOwnedRooms(subscribedRooms); + throw new Meteor.Error('user-last-owner', '', rooms); + } + + relinquishRoomOwnerships(user._id, subscribedRooms, false); + } + Users.setUserActive(userId, active); if (user.username) { diff --git a/app/lib/server/methods/deleteUserOwnAccount.js b/app/lib/server/methods/deleteUserOwnAccount.js index 0b5670abd25cd..4a655856ec571 100644 --- a/app/lib/server/methods/deleteUserOwnAccount.js +++ b/app/lib/server/methods/deleteUserOwnAccount.js @@ -8,7 +8,7 @@ import { Users } from '../../../models'; import { deleteUser } from '../functions'; Meteor.methods({ - deleteUserOwnAccount(password) { + deleteUserOwnAccount(password, confirmRelinquish) { check(password, String); if (!Meteor.userId()) { @@ -38,9 +38,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-username', 'Invalid username', { method: 'deleteUserOwnAccount' }); } - Meteor.defer(function() { - deleteUser(userId); - }); + deleteUser(userId, confirmRelinquish); return true; }, diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index e991c1d925a1c..1183865ab2881 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -472,6 +472,15 @@ export class Subscriptions extends Base { return this.find(query, options); } + findByUserIdExceptType(userId, typeException, options) { + const query = { + 'u._id': userId, + t: { $ne: typeException }, + }; + + return this.find(query, options); + } + findByUserIdAndType(userId, type, options) { const query = { 'u._id': userId, diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 5f9374fac4a66..d5ef36e75ef1f 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -549,6 +549,15 @@ export class Users extends Base { return this.findOne(query, options); } + findOneActiveById(userId, options) { + const query = { + _id: userId, + active: true, + }; + + return this.findOne(query, options); + } + findOneByIdOrUsername(idOrUsername, options) { const query = { $or: [{ diff --git a/app/ui-account/client/accountProfile.js b/app/ui-account/client/accountProfile.js index 12f01b5b54b04..4fabb780af709 100644 --- a/app/ui-account/client/accountProfile.js +++ b/app/ui-account/client/accountProfile.js @@ -9,11 +9,11 @@ import _ from 'underscore'; import s from 'underscore.string'; import toastr from 'toastr'; -import { modal, SideNav, popover } from '../../ui-utils'; -import { t, handleError } from '../../utils'; -import { settings } from '../../settings'; -import { Notifications } from '../../notifications'; -import { callbacks } from '../../callbacks'; +import { modal, SideNav, warnUserDeletionMayRemoveRooms, popover } from '../../ui-utils/client'; +import { t, handleError } from '../../utils/client'; +import { settings } from '../../settings/client'; +import { Notifications } from '../../notifications/client'; +import { callbacks } from '../../callbacks/client'; import { getPopoverStatusConfig } from '../../ui/client'; const validateEmail = (email) => /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(email); @@ -492,6 +492,31 @@ Template.accountProfile.events({ 'click .js-delete-account'(e) { e.preventDefault(); const user = Meteor.user(); + + const deleteOwnAccount = (deleteConfirmation, confirmRelinquish = false) => { + Meteor.call('deleteUserOwnAccount', deleteConfirmation, confirmRelinquish, function(error) { + if (error) { + toastr.remove(); + + if (error.error === 'user-last-owner') { + const { shouldChangeOwner, shouldBeRemoved } = error.details; + warnUserDeletionMayRemoveRooms(user._id, () => deleteOwnAccount(deleteConfirmation, true), { + confirmButtonKey: 'Continue', + closeOnConfirm: true, + skipModalIfEmpty: true, + shouldChangeOwner, + shouldBeRemoved, + }); + return; + } + + modal.showInputError(t('Your_password_is_wrong')); + } else { + modal.close(); + } + }); + }; + if (s.trim(user && user.services && user.services.password && user.services.password.bcrypt)) { modal.open({ title: t('Are_you_sure_you_want_to_delete_your_account'), @@ -506,14 +531,8 @@ Template.accountProfile.events({ if (typedPassword) { toastr.remove(); toastr.warning(t('Please_wait_while_your_account_is_being_deleted')); - Meteor.call('deleteUserOwnAccount', SHA256(typedPassword), function(error) { - if (error) { - toastr.remove(); - modal.showInputError(t('Your_password_is_wrong')); - } else { - modal.close(); - } - }); + + deleteOwnAccount(SHA256(typedPassword)); } else { modal.showInputError(t('You_need_to_type_in_your_password_in_order_to_do_this')); return false; @@ -533,14 +552,8 @@ Template.accountProfile.events({ if (deleteConfirmation === (user && user.username)) { toastr.remove(); toastr.warning(t('Please_wait_while_your_account_is_being_deleted')); - Meteor.call('deleteUserOwnAccount', deleteConfirmation, function(error) { - if (error) { - toastr.remove(); - modal.showInputError(t('Your_password_is_wrong')); - } else { - modal.close(); - } - }); + + deleteOwnAccount(deleteConfirmation); } else { modal.showInputError(t('You_need_to_type_in_your_username_in_order_to_do_this')); return false; diff --git a/app/ui-flextab/client/tabs/userActions.js b/app/ui-flextab/client/tabs/userActions.js index fa2f4bc439bc9..deda9cbc7715b 100644 --- a/app/ui-flextab/client/tabs/userActions.js +++ b/app/ui-flextab/client/tabs/userActions.js @@ -6,11 +6,11 @@ import toastr from 'toastr'; import _ from 'underscore'; import { WebRTC } from '../../../webrtc/client'; -import { ChatRoom, ChatSubscription, RoomRoles, Subscriptions } from '../../../models'; -import { modal } from '../../../ui-utils'; +import { ChatRoom, ChatSubscription, RoomRoles, Subscriptions } from '../../../models/client'; +import { modal } from '../../../ui-utils/client'; import { t, handleError, roomTypes } from '../../../utils'; -import { settings } from '../../../settings'; -import { hasPermission, hasAllPermission, userHasAllPermission } from '../../../authorization'; +import { settings } from '../../../settings/client'; +import { hasPermission, hasAllPermission, userHasAllPermission } from '../../../authorization/client'; import { RoomMemberActions } from '../../../utils/client'; const canSetLeader = () => hasAllPermission('set-leader', Session.get('openedRoom')); @@ -442,6 +442,7 @@ export const getActions = ({ user, directActions, hideAdminControls }) => { this.editingUser.set(user._id); }), }, { + // deprecated, this action should not be called as this component is not used on admin pages anymore icon: 'trash', name: 'Delete', action: prevent(getUser, ({ _id }) => { @@ -502,6 +503,7 @@ export const getActions = ({ user, directActions, hideAdminControls }) => { ), }; }, () => { + // deprecated, this action should not be called as this component is not used on admin pages anymore if (hideAdminControls || !hasPermission('edit-other-user-active-status')) { return; } diff --git a/app/ui-utils/client/index.js b/app/ui-utils/client/index.js index 5dbb840cc4f90..612e01195e221 100644 --- a/app/ui-utils/client/index.js +++ b/app/ui-utils/client/index.js @@ -24,6 +24,7 @@ export { MessageTypes } from '../lib/MessageTypes'; export { alerts } from './lib/alerts'; export { Message } from '../lib/Message'; export { openRoom } from './lib/openRoom'; +export { warnUserDeletionMayRemoveRooms } from './lib/warnUserDeletionMayRemoveRooms'; export * from './lib/rtl'; export * from './lib/keyCodes'; export * from './lib/prependReplies'; diff --git a/app/ui-utils/client/lib/warnUserDeletionMayRemoveRooms.js b/app/ui-utils/client/lib/warnUserDeletionMayRemoveRooms.js new file mode 100644 index 0000000000000..31c36278c9969 --- /dev/null +++ b/app/ui-utils/client/lib/warnUserDeletionMayRemoveRooms.js @@ -0,0 +1,63 @@ +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +import { t } from '../../../utils/client'; +import { modal } from './modal'; + +export const warnUserDeletionMayRemoveRooms = async function(userId, callbackFn, { warningKey, confirmButtonKey, closeOnConfirm = false, skipModalIfEmpty = false, shouldChangeOwner, shouldBeRemoved }) { + let warningText = warningKey ? t(warningKey) : false; + + if (shouldBeRemoved.length + shouldChangeOwner.length === 0 && skipModalIfEmpty) { + callbackFn(); + return; + } + + if (shouldChangeOwner.length > 0) { + let newText; + + if (shouldChangeOwner.length === 1) { + newText = TAPi18n.__('A_new_owner_will_be_assigned_automatically_to_the__roomName__room', { roomName: shouldChangeOwner.pop() }); + } else if (shouldChangeOwner.length <= 5) { + newText = TAPi18n.__('A_new_owner_will_be_assigned_automatically_to_those__count__rooms__rooms__', { count: shouldChangeOwner.length, rooms: shouldChangeOwner.join(', ') }); + } else { + newText = TAPi18n.__('A_new_owner_will_be_assigned_automatically_to__count__rooms', { count: shouldChangeOwner.length }); + } + + if (warningText) { + warningText = `${ warningText }

 

${ newText }

`; + } else { + warningText = newText; + } + } + + if (shouldBeRemoved.length > 0) { + let newText; + + if (shouldBeRemoved.length === 1) { + newText = TAPi18n.__('The_empty_room__roomName__will_be_removed_automatically', { roomName: shouldBeRemoved.pop() }); + } else if (shouldBeRemoved.length <= 5) { + newText = TAPi18n.__('__count__empty_rooms_will_be_removed_automatically__rooms__', { count: shouldBeRemoved.length, rooms: shouldBeRemoved.join(', ') }); + } else { + newText = TAPi18n.__('__count__empty_rooms_will_be_removed_automatically', { count: shouldBeRemoved.length }); + } + + if (warningText) { + warningText = `${ warningText }

 

${ newText }

`; + } else { + warningText = newText; + } + } + + modal.open({ + title: t('Are_you_sure'), + text: warningText, + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t(confirmButtonKey || 'Yes_delete_it'), + cancelButtonText: t('Cancel'), + closeOnConfirm, + html: true, + }, () => { + callbackFn(); + }); +}; diff --git a/client/admin/users/UserInfoActions.js b/client/admin/users/UserInfoActions.js index 7dd3551035f5e..31604c6be6ec9 100644 --- a/client/admin/users/UserInfoActions.js +++ b/client/admin/users/UserInfoActions.js @@ -6,9 +6,9 @@ import { useTranslation } from '../../contexts/TranslationContext'; import { useRoute } from '../../contexts/RouterContext'; import { usePermission } from '../../contexts/AuthorizationContext'; import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; -import { useMethod } from '../../contexts/ServerContext'; +import { useMethod, useEndpoint } from '../../contexts/ServerContext'; import { useSetting } from '../../contexts/SettingsContext'; -import { useEndpointAction } from '../../hooks/useEndpointAction'; +import RawText from '../../components/basic/RawText'; const DeleteWarningModal = ({ onDelete, onCancel, ...props }) => { @@ -33,6 +33,52 @@ const DeleteWarningModal = ({ onDelete, onCancel, ...props }) => { ; }; +const ConfirmOwnerChangeWarningModal = ({ onConfirm, onCancel, contentTitle = '', confirmLabel = '', shouldChangeOwner, shouldBeRemoved, ...props }) => { + const t = useTranslation(); + + let changeOwnerRooms = ''; + if (shouldChangeOwner.length > 0) { + if (shouldChangeOwner.length === 1) { + changeOwnerRooms = t('A_new_owner_will_be_assigned_automatically_to_the__roomName__room', { roomName: shouldChangeOwner.pop() }); + } else if (shouldChangeOwner.length <= 5) { + changeOwnerRooms = t('A_new_owner_will_be_assigned_automatically_to_those__count__rooms__rooms__', { count: shouldChangeOwner.length, rooms: shouldChangeOwner.join(', ') }); + } else { + changeOwnerRooms = t('A_new_owner_will_be_assigned_automatically_to__count__rooms', { count: shouldChangeOwner.length }); + } + } + + let removedRooms = ''; + if (shouldBeRemoved.length > 0) { + if (shouldBeRemoved.length === 1) { + removedRooms = t('The_empty_room__roomName__will_be_removed_automatically', { roomName: shouldBeRemoved.pop() }); + } else if (shouldBeRemoved.length <= 5) { + removedRooms = t('__count__empty_rooms_will_be_removed_automatically__rooms__', { count: shouldBeRemoved.length, rooms: shouldBeRemoved.join(', ') }); + } else { + removedRooms = t('__count__empty_rooms_will_be_removed_automatically', { count: shouldBeRemoved.length }); + } + } + + return + + + {t('Are_you_sure')} + + + + {contentTitle} + + { changeOwnerRooms && {changeOwnerRooms} } + { removedRooms && {removedRooms} } + + + + + + + + ; +}; + const SuccessModal = ({ onClose, ...props }) => { const t = useTranslation(); return @@ -67,20 +113,52 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange, .. const canEditOtherUserActiveStatus = usePermission('edit-other-user-active-status'); const canDeleteUser = usePermission('delete-user'); + const confirmOwnerChanges = (action, modalProps = {}) => async () => { + try { + return await action(); + } catch (error) { + if (error.xhr?.responseJSON?.errorType === 'user-last-owner') { + const { shouldChangeOwner, shouldBeRemoved } = error.xhr.responseJSON.details; + setModal( { + await action(true); + setModal(); + }} + onCancel={() => { setModal(); onChange(); }} + />); + return; + } + dispatchToastMessage({ type: 'error', message: error }); + } + }; + const deleteUserQuery = useMemo(() => ({ userId: _id }), [_id]); - const deleteUser = useEndpointAction('POST', 'users.delete', deleteUserQuery); + const deleteUserEndpoint = useEndpoint('POST', 'users.delete'); - const willDeleteUser = useCallback(async () => { - const result = await deleteUser(); + const erasureType = useSetting('Message_ErasureType'); + + const deleteUser = confirmOwnerChanges(async (confirm = false) => { + if (confirm) { + deleteUserQuery.confirmRelinquish = confirm; + } + + const result = await deleteUserEndpoint(deleteUserQuery); if (result.success) { setModal( { setModal(); onChange(); }}/>); } else { setModal(); } - }, [deleteUser]); + }, { + contentTitle: t(`Delete_User_Warning_${ erasureType }`), + confirmLabel: t('Delete'), + }); + const confirmDeleteUser = useCallback(() => { - setModal( setModal()}/>); - }, [deleteUser]); + setModal( setModal()}/>); + }, [deleteUserEndpoint]); const setAdminStatus = useMethod('setAdminStatus'); const changeAdminStatus = useCallback(() => { @@ -99,7 +177,25 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange, .. activeStatus: !isActive, }), [_id, isActive]); const changeActiveStatusMessage = isActive ? 'User_has_been_deactivated' : 'User_has_been_activated'; - const changeActiveStatus = useEndpointAction('POST', 'users.setActiveStatus', activeStatusQuery, t(changeActiveStatusMessage)); + const changeActiveStatusRequest = useEndpoint('POST', 'users.setActiveStatus'); + + const changeActiveStatus = confirmOwnerChanges(async (confirm = false) => { + if (confirm) { + activeStatusQuery.confirmRelinquish = confirm; + } + + try { + const result = await changeActiveStatusRequest(activeStatusQuery); + if (result.success) { + dispatchToastMessage({ type: 'success', message: t(changeActiveStatusMessage) }); + onChange(); + } + } catch (error) { + throw error; + } + }, { + confirmLabel: t('Yes_deactivate_it'), + }); const directMessageClick = () => directRoute.push({ rid: username, @@ -129,10 +225,7 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange, .. } }, ...canEditOtherUserActiveStatus && { changeActiveStatus: { label: <>{ isActive ? t('Deactivate') : t('Activate')}, - action: async () => { - const result = await changeActiveStatus(); - result.success ? onChange() : undefined; - }, + action: changeActiveStatus, } }, }), [canAssignAdminRole, canDeleteUser, canEditOtherUserActiveStatus, canEditOtherUserInfo, canDirectMessage, isActive, isAdmin]); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 9eae064e5cf43..4fb6734518702 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -9,9 +9,14 @@ "2_Erros_Information_and_Debug": "2 - Errors, Information and Debug", "@username": "@username", "@username_message": "@username ", + "__count__empty_rooms_will_be_removed_automatically": "__count__ empty rooms will be removed automatically.", + "__count__empty_rooms_will_be_removed_automatically__rooms__": "__count__ empty rooms will be removed automatically:
__rooms__.", "__username__is_no_longer__role__defined_by__user_by_": "__username__ is no longer __role__ by __user_by__", "__username__was_set__role__by__user_by_": "__username__ was set __role__ by __user_by__", "%_of_conversations": "% of Conversations", + "A_new_owner_will_be_assigned_automatically_to__count__rooms": "A new owner will be assigned automatically to __count__ rooms.", + "A_new_owner_will_be_assigned_automatically_to_the__roomName__room": "A new owner will be assigned automatically to the __roomName__ room.", + "A_new_owner_will_be_assigned_automatically_to_those__count__rooms__rooms__": "A new owner will be assigned automatically to those __count__ rooms:
__rooms__.", "Accept": "Accept", "Accept_incoming_livechat_requests_even_if_there_are_no_online_agents": "Accept incoming omnichannel requests even if there are no online agents", "Accept_new_livechats_when_agent_is_idle": "Accept new omnichannel requests when the agent is idle", @@ -3318,6 +3323,7 @@ "The_application_name_is_required": "The application name is required", "The_channel_name_is_required": "The channel name is required", "The_emails_are_being_sent": "The emails are being sent.", + "The_empty_room__roomName__will_be_removed_automatically": "The empty room __roomName__ will be removed automatically.", "The_field_is_required": "The field %s is required.", "The_image_resize_will_not_work_because_we_can_not_detect_ImageMagick_or_GraphicsMagick_installed_in_your_server": "The image resize will not work because we can not detect ImageMagick or GraphicsMagick installed on your server.", "The_message_is_a_discussion_you_will_not_be_able_to_recover": "The message is a discussion you will not be able to recover the messages!", @@ -3784,6 +3790,7 @@ "Yes_archive_it": "Yes, archive it!", "Yes_clear_all": "Yes, clear all!", "Yes_delete_it": "Yes, delete it!", + "Yes_deactivate_it": "Yes, deactivate it!", "Yes_hide_it": "Yes, hide it!", "Yes_leave_it": "Yes, leave it!", "Yes_mute_user": "Yes, mute user!", diff --git a/server/methods/deleteUser.js b/server/methods/deleteUser.js index f035d5b2f47ba..d8b0ce2e7bd32 100644 --- a/server/methods/deleteUser.js +++ b/server/methods/deleteUser.js @@ -3,10 +3,10 @@ import { check } from 'meteor/check'; import { Users } from '../../app/models'; import { hasPermission } from '../../app/authorization'; -import { deleteUser } from '../../app/lib'; +import { deleteUser } from '../../app/lib/server'; Meteor.methods({ - deleteUser(userId) { + deleteUser(userId, confirmRelinquish = false) { check(userId, String); if (!Meteor.userId()) { @@ -45,7 +45,7 @@ Meteor.methods({ }); } - deleteUser(userId); + deleteUser(userId, confirmRelinquish); return true; }, diff --git a/server/methods/setUserActiveStatus.js b/server/methods/setUserActiveStatus.js index d06e2aea0f3a1..adecec84054bf 100644 --- a/server/methods/setUserActiveStatus.js +++ b/server/methods/setUserActiveStatus.js @@ -5,7 +5,7 @@ import { hasPermission } from '../../app/authorization'; import { setUserActiveStatus } from '../../app/lib/server/functions/setUserActiveStatus'; Meteor.methods({ - setUserActiveStatus(userId, active) { + setUserActiveStatus(userId, active, confirmRelenquish) { check(userId, String); check(active, Boolean); @@ -21,7 +21,7 @@ Meteor.methods({ }); } - setUserActiveStatus(userId, active); + setUserActiveStatus(userId, active, confirmRelenquish); return true; },