From 272303cb585696ac21ce093faacb551ff49d7c43 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:17:40 +0300 Subject: [PATCH] Merge pull request #24907 from overleaf/ii-managed-users-make-unmanaged [web] Release users from managed group GitOrigin-RevId: 15921286af332d2294fb900ab3055991ca8b1673 --- .../app/src/Features/Email/EmailBuilder.js | 27 ++++ .../Subscription/SubscriptionUpdater.js | 15 ++ .../web/frontend/extracted-translations.json | 10 ++ .../members-table/dropdown-button.tsx | 17 ++- .../components/members-table/member-row.tsx | 3 + .../components/members-table/members-list.tsx | 10 ++ .../remove-managed-user-modal.tsx | 136 ++++++++++++++++++ services/web/locales/en.json | 10 ++ .../members-table/dropdown-button.spec.tsx | 8 ++ .../members-table/member-row.spec.tsx | 16 +++ .../remove-managed-user-modal.spec.tsx | 96 +++++++++++++ .../test/unit/src/Email/EmailBuilderTests.js | 37 +++++ .../Subscription/SubscriptionUpdaterTests.js | 33 +++++ 13 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 services/web/frontend/js/features/group-management/components/members-table/remove-managed-user-modal.tsx create mode 100644 services/web/test/frontend/features/group-management/components/members-table/remove-managed-user-modal.spec.tsx diff --git a/services/web/app/src/Features/Email/EmailBuilder.js b/services/web/app/src/Features/Email/EmailBuilder.js index 01565201ac..4741838b15 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.js +++ b/services/web/app/src/Features/Email/EmailBuilder.js @@ -949,6 +949,33 @@ templates.welcomeWithoutCTA = NoCTAEmailTemplate({ }, }) +templates.removeGroupMember = NoCTAEmailTemplate({ + subject(opts) { + return `Your ${settings.appName} account has been removed from ${opts.adminName}’s group` + }, + title(opts) { + return `Your ${settings.appName} account has been removed from ${opts.adminName}’s group` + }, + greeting() { + return '' + }, + message() { + const passwordResetUrl = `${settings.siteUrl}/user/password/reset` + + return [ + 'Don’t worry, your account and projects are still accessible. But there are a few changes to be aware of:', + '', + `If you think this has been done in error, please contact your group admin.`, + `Thanks!`, + `Team ${settings.appName}`, + ] + }, +}) + function _formatUserNameAndEmail(user, placeholder) { if (user.first_name && user.last_name) { const fullName = `${user.first_name} ${user.last_name}` diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index 98bd98b30e..70fa1a6403 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js @@ -10,6 +10,7 @@ const { DeletedSubscription } = require('../../models/DeletedSubscription') const logger = require('@overleaf/logger') const Features = require('../../infrastructure/Features') const UserAuditLogHandler = require('../User/UserAuditLogHandler') +const UserUpdater = require('../User/UserUpdater') const AccountMappingHelper = require('../Analytics/AccountMappingHelper') const { SSOConfig } = require('../../models/SSOConfig') const mongoose = require('../../infrastructure/Mongoose') @@ -145,6 +146,20 @@ async function removeUserFromGroup(subscriptionId, userId, auditLog) { { _id: subscriptionId }, { $pull: { member_ids: userId } } ).exec() + + const subscription = await Subscription.findById(subscriptionId) + if (subscription.managedUsersEnabled) { + await UserUpdater.promises.updateUser( + { _id: userId }, + { + $unset: { + 'enrollment.managedBy': 1, + 'enrollment.enrolledAt': 1, + }, + } + ) + } + await FeaturesUpdater.promises.refreshFeatures( userId, 'remove-user-from-group' diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 6b730db1a1..67071d4c2a 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -31,6 +31,7 @@ "about_to_enable_managed_users": "", "about_to_leave_project": "", "about_to_leave_projects": "", + "about_to_remove_user_preamble": "", "about_to_trash_projects": "", "abstract": "", "accept_and_continue": "", @@ -309,6 +310,7 @@ "confirm_reject_selected_changes": "", "confirm_reject_selected_changes_plural": "", "confirm_remove_sso_config_enter_email": "", + "confirm_remove_user_type_email_address": "", "confirm_secondary_email": "", "confirm_your_email": "", "confirming": "", @@ -1391,6 +1393,7 @@ "remove_secondary_email_addresses": "", "remove_sso_login_option": "", "remove_tag": "", + "remove_user": "", "removed_from_project": "", "removing": "", "rename": "", @@ -1764,6 +1767,12 @@ "there_is_an_unrecoverable_latex_error": "", "there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "", "they_lose_access_to_account": "", + "they_will_be_removed_from_the_group": "", + "they_will_continue_to_have_access_to_any_projects_shared_with_them": "", + "they_will_no_longer_be_a_managed_user": "", + "they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "", + "they_will_retain_their_existing_account_on_the_free_plan": "", + "they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "", "this_action_cannot_be_reversed": "", "this_action_cannot_be_undone": "", "this_address_will_be_shown_on_the_invoice": "", @@ -1990,6 +1999,7 @@ "user_deletion_error": "", "user_deletion_password_reset_tip": "", "user_first_name_attribute": "", + "user_has_left_organization_and_need_to_transfer_their_projects": "", "user_last_name_attribute": "", "user_sessions": "", "using_latex": "", diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx index b62e9ce391..6310fcf419 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx @@ -28,6 +28,7 @@ type resendInviteResponse = { type ManagedUserDropdownButtonProps = { user: User openOffboardingModalForUser: (user: User) => void + openRemoveModalForUser: (user: User) => void openUnlinkUserModal: (user: User) => void groupId: string setGroupUserAlert: Dispatch> @@ -36,6 +37,7 @@ type ManagedUserDropdownButtonProps = { export default function DropdownButton({ user, openOffboardingModalForUser, + openRemoveModalForUser, openUnlinkUserModal, groupId, setGroupUserAlert, @@ -172,6 +174,10 @@ export default function DropdownButton({ openOffboardingModalForUser(user) } + const onReleaseUserClick = () => { + openRemoveModalForUser(user) + } + const onRemoveFromGroup = () => { removeMember(user) } @@ -232,7 +238,6 @@ export default function DropdownButton({ if (isUserManaged && !user.isEntityAdmin) { buttons.push( ) + buttons.push( + + {t('remove_user')} + + ) } else if (!isUserManaged) { buttons.push( {t('remove_from_group')} diff --git a/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx b/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx index 2398cbbf9d..d898494959 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx @@ -16,6 +16,7 @@ import classnames from 'classnames' type ManagedUserRowProps = { user: User openOffboardingModalForUser: (user: User) => void + openRemoveModalForUser: (user: User) => void openUnlinkUserModal: (user: User) => void groupId: string setGroupUserAlert: Dispatch> @@ -24,6 +25,7 @@ type ManagedUserRowProps = { export default function MemberRow({ user, openOffboardingModalForUser, + openRemoveModalForUser, openUnlinkUserModal, setGroupUserAlert, groupId, @@ -112,6 +114,7 @@ export default function MemberRow({ ( undefined ) + const [userToRemove, setUserToRemove] = useState(undefined) const [groupUserAlert, setGroupUserAlert] = useState(undefined) const [userToUnlink, setUserToUnlink] = useState(undefined) @@ -101,6 +103,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) { key={user.email} user={user} openOffboardingModalForUser={setUserToOffboard} + openRemoveModalForUser={setUserToRemove} openUnlinkUserModal={setUserToUnlink} setGroupUserAlert={setGroupUserAlert} groupId={groupId} @@ -116,6 +119,13 @@ export default function MembersList({ groupId }: ManagedUsersListProps) { onClose={() => setUserToOffboard(undefined)} /> )} + {userToRemove && ( + setUserToRemove(undefined)} + /> + )} {userToUnlink && ( void +} + +export default function RemoveManagedUserModal({ + user, + groupId, + onClose, +}: RemoveManagedUserModalProps) { + const { t } = useTranslation() + const location = useLocation() + const { isLoading, isSuccess, error, setError, runAsync } = useAsync< + any, + any + >() + const [suppliedEmail, setSuppliedEmail] = useState() + const shouldEnableRemoveUserButton = suppliedEmail === user.email + const userFullName = user.last_name + ? `${user.first_name || ''} ${user.last_name || ''}` + : user.first_name + + const handleReleaseUserSubmit = (event: React.FormEvent) => { + event.preventDefault() + runAsync( + postJSON(`/manage/groups/${groupId}/release-managed-user/${user._id}`, { + body: { + verificationEmail: suppliedEmail, + }, + }) + .then(() => { + location.reload() + }) + .catch(err => { + setError( + err instanceof FetchError ? err.getUserFacingMessage() : err.message + ) + debugConsole.error(err) + }) + ) + } + + return ( + +
+ + {t('remove_user')} + + +

+ {t('about_to_remove_user_preamble', { + userName: userFullName, + userEmail: user.email, + })} +

+
    +
  • {t('they_will_be_removed_from_the_group')}
  • +
  • {t('they_will_no_longer_be_a_managed_user')}
  • +
  • + {t('they_will_retain_their_existing_account_on_the_free_plan')} +
  • +
  • + {t( + 'they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only' + )} +
  • +
  • + {t( + 'they_will_continue_to_have_access_to_any_projects_shared_with_them' + )} +
  • +
  • + {t( + 'they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password' + )} +
  • +
+

+ ]} // eslint-disable-line react/jsx-key + /> +

+ + + {t('confirm_remove_user_type_email_address', { + userName: userFullName, + })} + + setSuppliedEmail(e.target.value)} + /> + + {error && ( + + )} +
+ + + {t('cancel')} + + + {t('remove_user')} + + +
+
+ ) +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 837e4ea09a..1cedcbe101 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -34,6 +34,7 @@ "about_to_enable_managed_users": "By enabling the Managed Users feature, all existing members of your group subscription will be invited to become managed. This will give you admin rights over their account. You will also have the option to invite new members to join the subscription and become managed.", "about_to_leave_project": "You are about to leave this project.", "about_to_leave_projects": "You are about to leave the following projects:", + "about_to_remove_user_preamble": "You’re about to remove __userName__ (__userEmail__). Doing this will mean:", "about_to_trash_projects": "You are about to trash the following projects:", "abstract": "Abstract", "accept": "Accept", @@ -399,6 +400,7 @@ "confirm_reject_selected_changes": "Are you sure you want to reject the selected change?", "confirm_reject_selected_changes_plural": "Are you sure you want to reject the selected __count__ changes?", "confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:", + "confirm_remove_user_type_email_address": "To confirm you want to remove __userName__ please type the email address associated with their account.", "confirm_secondary_email": "Confirm secondary email", "confirm_your_email": "Confirm your email address", "confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.", @@ -1825,6 +1827,7 @@ "remove_secondary_email_addresses": "Remove any secondary email addresses associated with your account. <0>Remove them in account settings.", "remove_sso_login_option": "Remove the SSO login option for your users.", "remove_tag": "Remove tag __tagName__", + "remove_user": "Remove user", "removed": "removed", "removed_from_project": "Removed from project", "removing": "Removing", @@ -2288,6 +2291,12 @@ "there_was_an_error_opening_your_content": "There was an error creating your project", "thesis": "Thesis", "they_lose_access_to_account": "They lose all access to this Overleaf account immediately", + "they_will_be_removed_from_the_group": "They will be removed from the group.", + "they_will_continue_to_have_access_to_any_projects_shared_with_them": "They will continue to have access to any projects shared with them.", + "they_will_no_longer_be_a_managed_user": "They will no longer be a managed user.", + "they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "They will retain ownership of projects currently owned by them and any collaborators on those projects will become read-only.", + "they_will_retain_their_existing_account_on_the_free_plan": "They will retain their existing account on the __appName__ free plan.", + "they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "They won’t be able to log in with SSO (if you have this enabled). They will need to set an __appName__ password.", "this_action_cannot_be_reversed": "This action cannot be reversed.", "this_action_cannot_be_undone": "This action cannot be undone.", "this_address_will_be_shown_on_the_invoice": "This address will be shown on the invoice", @@ -2529,6 +2538,7 @@ "user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.", "user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as ORCID or Google), please <0>reset your password and try again.", "user_first_name_attribute": "User first name attribute", + "user_has_left_organization_and_need_to_transfer_their_projects": "In cases where a user has left your organization and you need to transfer their projects, the <0>Delete user option should be used.", "user_is_not_part_of_group": "User is not part of group", "user_last_name_attribute": "User last name attribute", "user_management": "User management", diff --git a/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx b/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx index 9213069699..9f2964941f 100644 --- a/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx @@ -24,6 +24,7 @@ function mountDropDownComponent(user: User, subscriptionId: string) { + ) + }) + + it('should render the modal', function () { + cy.findByTestId('release-user-form') + }) + + it('should render content', function () { + cy.findByText( + `You’re about to remove ${user.first_name} ${user.last_name} (${user.email}). Doing this will mean:` + ) + cy.findAllByRole('listitem') + .eq(0) + .contains(/they will be removed from the group/i) + cy.findAllByRole('listitem') + .eq(1) + .contains(/they will no longer be a managed user/i) + cy.findAllByRole('listitem') + .eq(2) + .contains( + /they will retain their existing account on the .* free plan/i + ) + cy.findAllByRole('listitem') + .eq(3) + .contains( + /they will retain ownership of projects currently owned by them and any collaborators on those projects will become read-only/i + ) + cy.findAllByRole('listitem') + .eq(4) + .contains( + /they will continue to have access to any projects shared with them/i + ) + cy.findAllByRole('listitem') + .eq(5) + .contains( + /they won’t be able to log in with SSO \(if you have this enabled\)\. they will need to set an .* password/i + ) + cy.contains( + /in cases where a user has left your organization and you need to transfer their projects, the delete user option should be used/i + ) + }) + + it('should disable the remove button if the email does not match the user', function () { + // Button should be disabled initially + cy.findByRole('button', { name: /remove user/i }).should('be.disabled') + + // Fill in the email input, with the wrong email address + cy.findByLabelText( + /to confirm you want to remove .* please type the email address associated with their account/i + ).type('totally.wrong@example.com') + + // Button still disabled + cy.findByRole('button', { name: /remove user/i }).should('be.disabled') + }) + + it('should fill out the form, and enable the remove button', function () { + // Button should be disabled initially + cy.findByRole('button', { name: /remove user/i }).should('be.disabled') + + // Fill in the email input + cy.findByLabelText( + /to confirm you want to remove .* please type the email address associated with their account/i + ).type(user.email) + + // Button should be enabled now + cy.findByRole('button', { name: /remove user/i }).should('be.enabled') + }) + }) +}) diff --git a/services/web/test/unit/src/Email/EmailBuilderTests.js b/services/web/test/unit/src/Email/EmailBuilderTests.js index a8a0dc1ad5..8cc83f0228 100644 --- a/services/web/test/unit/src/Email/EmailBuilderTests.js +++ b/services/web/test/unit/src/Email/EmailBuilderTests.js @@ -818,6 +818,43 @@ describe('EmailBuilder', function () { }) }) }) + + describe('removeGroupMember', function () { + beforeEach(function () { + this.passwordResetUrl = `${this.settings.siteUrl}/user/password/reset` + this.emailAddress = 'example@overleaf.com' + this.opts = { + to: this.emailAddress, + adminName: 'abcdef', + } + this.email = this.EmailBuilder.buildEmail( + 'removeGroupMember', + this.opts + ) + this.dom = cheerio.load(this.email.html) + }) + + it('should build the email', function () { + expect(this.email.html).to.exist + expect(this.email.text).to.exist + }) + + describe('HTML email', function () { + it('should include links', function () { + const resetPasswordLink = this.dom('a:contains("set a password")') + expect(resetPasswordLink.length).to.equal(1) + expect(resetPasswordLink.attr('href')).to.equal( + this.passwordResetUrl + ) + }) + }) + + describe('plain text email', function () { + it('should include URLs', function () { + expect(this.email.text).to.contain(this.passwordResetUrl) + }) + }) + }) }) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js b/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js index a122f0e4b2..d272ad51e4 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js @@ -70,6 +70,7 @@ describe('SubscriptionUpdater', function () { .stub() .returns({ exec: sinon.stub().resolves() }) this.SubscriptionModel.findOne = sinon.stub().resolves() + this.SubscriptionModel.findById = sinon.stub().resolves() this.SubscriptionModel.updateMany = sinon .stub() .returns({ exec: sinon.stub().resolves() }) @@ -173,6 +174,12 @@ describe('SubscriptionUpdater', function () { }, } + this.UserUpdater = { + promises: { + updateUser: sinon.stub().resolves(), + }, + } + this.SubscriptionUpdater = SandboxedModule.require(modulePath, { requires: { '../../models/Subscription': { @@ -193,6 +200,7 @@ describe('SubscriptionUpdater', function () { }), '../../infrastructure/Features': this.Features, '../User/UserAuditLogHandler': this.UserAuditLogHandler, + '../User/UserUpdater': this.UserUpdater, '../../infrastructure/Modules': (this.Modules = { promises: { hooks: { @@ -622,6 +630,9 @@ describe('SubscriptionUpdater', function () { }, ] this.SubscriptionModel.findOne.resolves(this.groupSubscription) + this.SubscriptionModel.findById = sinon + .stub() + .resolves(this.groupSubscription) this.SubscriptionLocator.promises.getMemberSubscriptions.resolves( this.fakeSubscriptions ) @@ -638,6 +649,28 @@ describe('SubscriptionUpdater', function () { .should.equal(true) }) + it('should remove user enrollment if the group is managed', async function () { + this.SubscriptionModel.findById.resolves({ + ...this.groupSubscription, + managedUsersEnabled: true, + }) + await this.SubscriptionUpdater.promises.removeUserFromGroup( + this.groupSubscription._id, + this.otherUserId + ) + this.UserUpdater.promises.updateUser + .calledWith( + { _id: this.otherUserId }, + { + $unset: { + 'enrollment.managedBy': 1, + 'enrollment.enrolledAt': 1, + }, + } + ) + .should.equal(true) + }) + it('should send a group-subscription-left event', async function () { await this.SubscriptionUpdater.promises.removeUserFromGroup( this.groupSubscription._id,