Просмотр исходного кода

Merge pull request #24907 from overleaf/ii-managed-users-make-unmanaged

[web] Release users from managed group

GitOrigin-RevId: 15921286af332d2294fb900ab3055991ca8b1673
ilkin-overleaf 1 год назад
Родитель
Сommit
272303cb58

+ 27 - 0
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:',
+      '<ul>' +
+        `<li>Your account will have reverted to a free ${settings.appName} plan.</li>`,
+      `<li>Any project collaborators have been set to read-only (you can invite one collaborator per project on the free plan).</li>`,
+      `<li>If you previously logged in via SSO, you’ll need to <a href="${passwordResetUrl}">set a password</a> to access your account.</li>` +
+        '</ul>',
+      `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}`

+ 15 - 0
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'

+ 10 - 0
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": "",

+ 15 - 2
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<SetStateAction<GroupUserAlert>>
@@ -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(
       <MenuItemButton
-        className="delete-user-action"
         key="delete-user-action"
         data-testid="delete-user-action"
         onClick={onDeleteUserClick}
@@ -240,13 +245,21 @@ export default function DropdownButton({
         {t('delete_user')}
       </MenuItemButton>
     )
+    buttons.push(
+      <MenuItemButton
+        key="release-user-action"
+        data-testid="release-user-action"
+        onClick={onReleaseUserClick}
+      >
+        {t('remove_user')}
+      </MenuItemButton>
+    )
   } else if (!isUserManaged) {
     buttons.push(
       <MenuItemButton
         key="remove-user-action"
         data-testid="remove-user-action"
         onClick={onRemoveFromGroup}
-        className="delete-user-action"
         variant="danger"
       >
         {t('remove_from_group')}

+ 3 - 0
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<SetStateAction<GroupUserAlert>>
@@ -24,6 +25,7 @@ type ManagedUserRowProps = {
 export default function MemberRow({
   user,
   openOffboardingModalForUser,
+  openRemoveModalForUser,
   openUnlinkUserModal,
   setGroupUserAlert,
   groupId,
@@ -112,6 +114,7 @@ export default function MemberRow({
         <DropdownButton
           user={user}
           openOffboardingModalForUser={openOffboardingModalForUser}
+          openRemoveModalForUser={openRemoveModalForUser}
           openUnlinkUserModal={openUnlinkUserModal}
           setGroupUserAlert={setGroupUserAlert}
           groupId={groupId}

+ 10 - 0
services/web/frontend/js/features/group-management/components/members-table/members-list.tsx

@@ -5,6 +5,7 @@ import { useGroupMembersContext } from '../../context/group-members-context'
 import type { GroupUserAlert } from '../../utils/types'
 import MemberRow from './member-row'
 import OffboardManagedUserModal from './offboard-managed-user-modal'
+import RemoveManagedUserModal from '@/features/group-management/components/members-table/remove-managed-user-modal'
 import ListAlert from './list-alert'
 import SelectAllCheckbox from './select-all-checkbox'
 import classNames from 'classnames'
@@ -22,6 +23,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
   const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
     undefined
   )
+  const [userToRemove, setUserToRemove] = useState<User | undefined>(undefined)
   const [groupUserAlert, setGroupUserAlert] =
     useState<GroupUserAlert>(undefined)
   const [userToUnlink, setUserToUnlink] = useState<User | undefined>(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 && (
+        <RemoveManagedUserModal
+          user={userToRemove}
+          groupId={groupId}
+          onClose={() => setUserToRemove(undefined)}
+        />
+      )}
       {userToUnlink && (
         <UnlinkUserModal
           user={userToUnlink}

+ 136 - 0
services/web/frontend/js/features/group-management/components/members-table/remove-managed-user-modal.tsx

@@ -0,0 +1,136 @@
+import { User } from '../../../../../../types/group-management/user'
+import { useState } from 'react'
+import useAsync from '@/shared/hooks/use-async'
+import { useTranslation, Trans } from 'react-i18next'
+import { useLocation } from '@/shared/hooks/use-location'
+import { FetchError, postJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+
+type RemoveManagedUserModalProps = {
+  user: User
+  groupId: string
+  onClose: () => 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<string>()
+  const shouldEnableRemoveUserButton = suppliedEmail === user.email
+  const userFullName = user.last_name
+    ? `${user.first_name || ''} ${user.last_name || ''}`
+    : user.first_name
+
+  const handleReleaseUserSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+    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 (
+    <OLModal id={`release-user-modal-${user._id}`} show onHide={onClose}>
+      <form onSubmit={handleReleaseUserSubmit} data-testid="release-user-form">
+        <OLModalHeader>
+          <OLModalTitle>{t('remove_user')}</OLModalTitle>
+        </OLModalHeader>
+        <OLModalBody>
+          <p>
+            {t('about_to_remove_user_preamble', {
+              userName: userFullName,
+              userEmail: user.email,
+            })}
+          </p>
+          <ul>
+            <li>{t('they_will_be_removed_from_the_group')}</li>
+            <li>{t('they_will_no_longer_be_a_managed_user')}</li>
+            <li>
+              {t('they_will_retain_their_existing_account_on_the_free_plan')}
+            </li>
+            <li>
+              {t(
+                'they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only'
+              )}
+            </li>
+            <li>
+              {t(
+                'they_will_continue_to_have_access_to_any_projects_shared_with_them'
+              )}
+            </li>
+            <li>
+              {t(
+                'they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password'
+              )}
+            </li>
+          </ul>
+          <p>
+            <Trans
+              i18nKey="user_has_left_organization_and_need_to_transfer_their_projects"
+              components={[<b />]} // eslint-disable-line react/jsx-key
+            />
+          </p>
+          <OLFormGroup controlId="supplied-email-input">
+            <OLFormLabel>
+              {t('confirm_remove_user_type_email_address', {
+                userName: userFullName,
+              })}
+            </OLFormLabel>
+            <OLFormControl
+              type="email"
+              aria-label={t('email')}
+              onChange={e => setSuppliedEmail(e.target.value)}
+            />
+          </OLFormGroup>
+          {error && (
+            <OLNotification type="error" content={error} className="mb-0" />
+          )}
+        </OLModalBody>
+        <OLModalFooter>
+          <OLButton variant="secondary" onClick={onClose}>
+            {t('cancel')}
+          </OLButton>
+          <OLButton
+            type="submit"
+            variant="danger"
+            disabled={isLoading || isSuccess || !shouldEnableRemoveUserButton}
+            isLoading={isLoading}
+          >
+            {t('remove_user')}
+          </OLButton>
+        </OLModalFooter>
+      </form>
+    </OLModal>
+  )
+}

+ 10 - 0
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.</0>",
   "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</0> 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</0> 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",

+ 8 - 0
services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx

@@ -24,6 +24,7 @@ function mountDropDownComponent(user: User, subscriptionId: string) {
       <DropdownButton
         user={user}
         openOffboardingModalForUser={sinon.stub()}
+        openRemoveModalForUser={sinon.stub()}
         openUnlinkUserModal={sinon.stub()}
         groupId={subscriptionId}
         setGroupUserAlert={sinon.stub()}
@@ -189,6 +190,7 @@ describe('DropdownButton', function () {
         cy.findByRole('button', { name: /actions/i }).click()
 
         cy.findByTestId('delete-user-action').should('be.visible')
+        cy.findByTestId('release-user-action')
 
         cy.findByTestId('remove-user-action').should('not.exist')
         cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
@@ -566,6 +568,7 @@ describe('DropdownButton', function () {
         cy.findByTestId('unlink-user-action').should('be.visible')
 
         cy.findByTestId('delete-user-action').should('not.exist')
+        cy.findByTestId('release-user-action').should('not.exist')
         cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
         cy.findByTestId('no-actions-available').should('not.exist')
       })
@@ -608,6 +611,7 @@ describe('DropdownButton', function () {
         )
         cy.findByTestId('remove-user-action').should('be.visible')
         cy.findByTestId('delete-user-action').should('not.exist')
+        cy.findByTestId('release-user-action').should('not.exist')
         cy.findByTestId('resend-sso-link-invite-action').should('exist')
 
         cy.findByTestId('no-actions-available').should('not.exist')
@@ -648,6 +652,7 @@ describe('DropdownButton', function () {
         cy.findByRole('button', { name: /actions/i }).click()
 
         cy.findByTestId('delete-user-action').should('be.visible')
+        cy.findByTestId('release-user-action')
 
         cy.findByTestId('remove-user-action').should('not.exist')
         cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
@@ -697,6 +702,7 @@ describe('DropdownButton', function () {
         cy.findByRole('button', { name: /actions/i }).click()
 
         cy.findByTestId('delete-user-action').should('be.visible')
+        cy.findByTestId('release-user-action')
 
         cy.findByTestId('remove-user-action').should('not.exist')
         cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
@@ -745,6 +751,7 @@ describe('DropdownButton', function () {
         cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
         cy.findByTestId('remove-user-action').should('not.exist')
         cy.findByTestId('delete-user-action').should('not.exist')
+        cy.findByTestId('release-user-action').should('not.exist')
         cy.findByTestId('no-actions-available').should('not.exist')
       })
     })
@@ -793,6 +800,7 @@ describe('DropdownButton', function () {
 
         cy.findByTestId('no-actions-available').should('not.exist')
         cy.findByTestId('delete-user-action').should('not.exist')
+        cy.findByTestId('release-user-action').should('not.exist')
         cy.findByTestId('remove-user-action').should('not.exist')
         cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
         cy.findByTestId('resend-sso-link-invite-action').should('not.exist')

+ 16 - 0
services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx

@@ -30,6 +30,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -82,6 +83,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -121,6 +123,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -159,6 +162,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -207,6 +211,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -258,6 +263,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -297,6 +303,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -335,6 +342,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -383,6 +391,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -435,6 +444,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -474,6 +484,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -512,6 +523,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -561,6 +573,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -613,6 +626,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -652,6 +666,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}
@@ -690,6 +705,7 @@ describe('MemberRow', function () {
             <MemberRow
               user={user}
               openOffboardingModalForUser={sinon.stub()}
+              openRemoveModalForUser={sinon.stub()}
               openUnlinkUserModal={sinon.stub()}
               groupId={subscriptionId}
               setGroupUserAlert={sinon.stub()}

+ 96 - 0
services/web/test/frontend/features/group-management/components/members-table/remove-managed-user-modal.spec.tsx

@@ -0,0 +1,96 @@
+import RemoveManagedUserModal from '@/features/group-management/components/members-table/remove-managed-user-modal'
+import sinon from 'sinon'
+
+describe('RemoveManagedUserModal', function () {
+  describe('happy path', function () {
+    const groupId = 'some-group'
+    const user = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: true,
+      last_active_at: new Date(),
+      enrollment: {
+        managedBy: `${groupId}`,
+        enrolledAt: new Date(),
+      },
+      isEntityAdmin: undefined,
+    }
+
+    beforeEach(function () {
+      cy.mount(
+        <RemoveManagedUserModal
+          user={user}
+          groupId={groupId}
+          onClose={sinon.stub()}
+        />
+      )
+    })
+
+    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')
+    })
+  })
+})

+ 37 - 0
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)
+          })
+        })
+      })
     })
   })
 })

+ 33 - 0
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,