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

Merge pull request #13604 from overleaf/jk-managed-users-offboarding-ui

[web] Managed Users offboarding UI

GitOrigin-RevId: ee4a1ae7cdb0022839ef232836ef6933443400fc
Tim Down 3 лет назад
Родитель
Сommit
49eafa2712
17 измененных файлов с 508 добавлено и 84 удалено
  1. 4 2
      services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js
  2. 24 0
      services/web/app/src/Features/Email/EmailBuilder.js
  3. 10 2
      services/web/app/src/Features/Tags/TagsHandler.js
  4. 12 0
      services/web/frontend/extracted-translations.json
  5. 1 0
      services/web/frontend/js/features/group-management/components/group-members.tsx
  6. 34 21
      services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx
  7. 9 2
      services/web/frontend/js/features/group-management/components/managed-users/managed-user-row.tsx
  8. 72 53
      services/web/frontend/js/features/group-management/components/managed-users/managed-users-list.tsx
  9. 164 0
      services/web/frontend/js/features/group-management/components/managed-users/offboard-managed-user-modal.tsx
  10. 5 2
      services/web/frontend/stylesheets/components/group-members.less
  11. 12 0
      services/web/locales/en.json
  12. 13 2
      services/web/test/frontend/features/group-management/components/managed-users/managed-user-dropdown-button.spec.tsx
  13. 6 0
      services/web/test/frontend/features/group-management/components/managed-users/managed-user-row.spec.tsx
  14. 3 0
      services/web/test/frontend/features/group-management/components/managed-users/managed-users-list.spec.tsx
  15. 107 0
      services/web/test/frontend/features/group-management/components/managed-users/offboard-managed-user-modal.spec.tsx
  16. 9 0
      services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js
  17. 23 0
      services/web/test/unit/src/Tags/TagsHandlerTests.js

+ 4 - 2
services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js

@@ -15,7 +15,7 @@ module.exports = {
 }
 
 async function transferOwnership(projectId, newOwnerId, options = {}) {
-  const { allowTransferToNonCollaborators, sessionUserId } = options
+  const { allowTransferToNonCollaborators, sessionUserId, skipEmails } = options
 
   // Fetch project and user
   const [project, newOwner] = await Promise.all([
@@ -58,7 +58,9 @@ async function transferOwnership(projectId, newOwnerId, options = {}) {
 
   // Send confirmation emails
   const previousOwner = await UserGetter.promises.getUser(previousOwnerId)
-  await _sendEmails(project, previousOwner, newOwner)
+  if (!skipEmails) {
+    await _sendEmails(project, previousOwner, newOwner)
+  }
 }
 
 async function _getProject(projectId) {

+ 24 - 0
services/web/app/src/Features/Email/EmailBuilder.js

@@ -498,6 +498,30 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
   },
 })
 
+templates.managedUserOffboarded = ctaTemplate({
+  subject() {
+    return `Your account has been deleted - ${settings.appName}`
+  },
+  title() {
+    return `Your account has been deleted`
+  },
+  message() {
+    return [
+      'Your group administrator has deleted your account. Contact your administrator to learn more about it.',
+      'You can create a new account if you want to use a different email address.',
+    ]
+  },
+  secondaryMessage() {
+    return []
+  },
+  ctaText() {
+    return 'Create a new account'
+  },
+  ctaURL() {
+    return `${settings.siteUrl}/register`
+  },
+})
+
 templates.securityAlert = NoCTAEmailTemplate({
   subject(opts) {
     return `Overleaf security note: ${opts.action}`

+ 10 - 2
services/web/app/src/Features/Tags/TagsHandler.js

@@ -7,12 +7,20 @@ function getAllTags(userId, callback) {
   Tag.find({ user_id: userId }, callback)
 }
 
-function createTag(userId, name, color, callback) {
+function createTag(userId, name, color, options, callback) {
+  if (typeof options === 'function') {
+    callback = options
+    options = {}
+  }
   if (!callback) {
     callback = function () {}
   }
   if (name.length > MAX_TAG_LENGTH) {
-    return callback(new Error('Exceeded max tag length'))
+    if (options.truncate) {
+      name = name.slice(0, MAX_TAG_LENGTH)
+    } else {
+      return callback(new Error('Exceeded max tag length'))
+    }
   }
   Tag.create({ user_id: userId, name, color }, function (err, tag) {
     // on duplicate key error return existing tag

+ 12 - 0
services/web/frontend/extracted-translations.json

@@ -10,6 +10,7 @@
   "about_to_delete_tag": "",
   "about_to_delete_the_following_project": "",
   "about_to_delete_the_following_projects": "",
+  "about_to_delete_user_preamble": "",
   "about_to_enable_managed_users": "",
   "about_to_leave_projects": "",
   "about_to_trash_projects": "",
@@ -53,6 +54,7 @@
   "all_premium_features": "",
   "all_premium_features_including": "",
   "all_projects": "",
+  "all_projects_will_be_transferred_immediately": "",
   "also": "",
   "an_error_occurred_when_verifying_the_coupon_code": "",
   "anonymous": "",
@@ -132,6 +134,7 @@
   "checking_dropbox_status": "",
   "checking_project_github_status": "",
   "choose_a_custom_color": "",
+  "choose_from_group_members": "",
   "clear_cached_files": "",
   "clear_search": "",
   "click_here_to_view_sl_in_lng": "",
@@ -163,6 +166,7 @@
   "confirm": "",
   "confirm_affiliation": "",
   "confirm_affiliation_to_relink_dropbox": "",
+  "confirm_delete_user_type_email_address": "",
   "confirm_new_password": "",
   "confirm_primary_email_change": "",
   "conflicting_paths_found": "",
@@ -537,6 +541,7 @@
   "layout_processing": "",
   "learn_more": "",
   "learn_more_about_link_sharing": "",
+  "learn_more_about_managed_users": "",
   "leave": "",
   "leave_any_group_subscriptions": "",
   "leave_group": "",
@@ -887,6 +892,7 @@
   "see_changes_in_your_documents_live": "",
   "select_a_file": "",
   "select_a_file_figure_modal": "",
+  "select_a_new_owner_for_projects": "",
   "select_a_payment_method": "",
   "select_a_project": "",
   "select_a_project_figure_modal": "",
@@ -1006,9 +1012,12 @@
   "thanks_settings_updated": "",
   "the_following_files_already_exist_in_this_project": "",
   "the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
+  "their_projects_will_be_transferred_to_another_user": "",
   "then_x_price_per_month": "",
   "then_x_price_per_year": "",
   "there_are_lots_of_options_to_edit_and_customize_your_figures": "",
+  "they_lose_access_to_account": "",
+  "this_action_cannot_be_reversed": "",
   "this_action_cannot_be_undone": "",
   "this_address_will_be_shown_on_the_invoice": "",
   "this_field_is_required": "",
@@ -1070,6 +1079,8 @@
   "transfer_management_of_your_account": "",
   "transfer_management_of_your_account_to_x": "",
   "transfer_management_resolve_following_issues": "",
+  "transfer_this_users_projects": "",
+  "transfer_this_users_projects_description": "",
   "transferring": "",
   "trash": "",
   "trash_projects": "",
@@ -1187,6 +1198,7 @@
   "you_have_added_x_of_group_size_y": "",
   "you_have_been_invited_to_transfer_management_of_your_account": "",
   "you_have_been_invited_to_transfer_management_of_your_account_to": "",
+  "you_will_be_able_to_reassign_subscription": "",
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_git_access_info": "",

+ 1 - 0
services/web/frontend/js/features/group-management/components/group-members.tsx

@@ -203,6 +203,7 @@ export default function GroupMembers() {
                   users={users}
                   selectUser={selectUser}
                   unselectUser={unselectUser}
+                  groupId={groupId}
                 />
               ) : (
                 <GroupMembersList

+ 34 - 21
services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx

@@ -2,39 +2,52 @@ import { Dropdown, MenuItem } from 'react-bootstrap'
 import { User } from '../../../../../../types/group-management/user'
 import ControlledDropdown from '../../../../shared/components/controlled-dropdown'
 import { useTranslation } from 'react-i18next'
+import MenuItemButton from '../../../project-list/components/dropdown/menu-item-button'
 
 type ManagedUserDropdownButtonProps = {
   user: User
+  openOffboardingModalForUser: (user: User) => void
 }
 
 export default function ManagedUserDropdownButton({
   user,
+  openOffboardingModalForUser,
 }: ManagedUserDropdownButtonProps) {
   const { t } = useTranslation()
+
+  const onDeleteUserClick = () => {
+    openOffboardingModalForUser(user)
+  }
+
   return (
-    <ControlledDropdown id={`managed-user-dropdown-${user._id}`}>
-      <Dropdown.Toggle
-        bsStyle={null}
-        className="btn btn-link action-btn"
-        noCaret
-      >
-        <i
-          className="fa fa-ellipsis-v"
-          aria-hidden="true"
-          aria-label={t('actions')}
-        />
-      </Dropdown.Toggle>
-      <Dropdown.Menu className="dropdown-menu-right">
-        {user.enrollment ? (
-          <MenuItem className="delete-user-action">{t('delete_user')}</MenuItem>
-        ) : (
-          <>
+    <>
+      <ControlledDropdown id={`managed-user-dropdown-${user._id}`}>
+        <Dropdown.Toggle
+          bsStyle={null}
+          className="btn btn-link action-btn"
+          noCaret
+        >
+          <i
+            className="fa fa-ellipsis-v"
+            aria-hidden="true"
+            aria-label={t('actions')}
+          />
+        </Dropdown.Toggle>
+        <Dropdown.Menu className="dropdown-menu-right">
+          {user.enrollment ? (
+            <MenuItemButton
+              className="delete-user-action"
+              onClick={onDeleteUserClick}
+            >
+              {t('delete_user')}
+            </MenuItemButton>
+          ) : (
             <MenuItem className="no-actions-available">
               <span className="text-muted">{t('no_actions')}</span>
             </MenuItem>
-          </>
-        )}
-      </Dropdown.Menu>
-    </ControlledDropdown>
+          )}
+        </Dropdown.Menu>
+      </ControlledDropdown>
+    </>
   )
 }

+ 9 - 2
services/web/frontend/js/features/group-management/components/managed-users/managed-user-row.tsx

@@ -13,6 +13,7 @@ type ManagedUserRowProps = {
   selectUser: (user: User) => void
   unselectUser: (user: User) => void
   selected: boolean
+  openOffboardingModalForUser: (user: User) => void
 }
 
 export default function ManagedUserRow({
@@ -20,6 +21,7 @@ export default function ManagedUserRow({
   selectUser,
   unselectUser,
   selected,
+  openOffboardingModalForUser,
 }: ManagedUserRowProps) {
   const { t } = useTranslation()
 
@@ -86,7 +88,9 @@ export default function ManagedUserRow({
           </span>
         </Col>
         <Col xs={2}>
-          {user.first_name} {user.last_name}
+          <span>
+            {user.first_name} {user.last_name}
+          </span>
         </Col>
         <Col xs={2}>
           {user.last_active_at
@@ -97,7 +101,10 @@ export default function ManagedUserRow({
           <span className="pull-right">
             <ManagedUserStatus user={user} />
             <span className="managed-user-actions">
-              <ManagedUserDropdownButton user={user} />
+              <ManagedUserDropdownButton
+                user={user}
+                openOffboardingModalForUser={openOffboardingModalForUser}
+              />
             </span>
           </span>
         </Col>

+ 72 - 53
services/web/frontend/js/features/group-management/components/managed-users/managed-users-list.tsx

@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
 import { User } from '../../../../../../types/group-management/user'
 import Tooltip from '../../../../shared/components/tooltip'
 import ManagedUserRow from './managed-user-row'
+import OffboardManagedUserModal from './offboard-managed-user-modal'
+import { useState } from 'react'
 
 type ManagedUsersListProps = {
   handleSelectAllClick: (e: any) => void
@@ -10,6 +12,7 @@ type ManagedUsersListProps = {
   users: User[]
   selectUser: (user: User) => void
   unselectUser: (user: User) => void
+  groupId: string
 }
 
 export default function ManagedUsersList({
@@ -18,65 +21,81 @@ export default function ManagedUsersList({
   users,
   selectUser,
   unselectUser,
+  groupId,
 }: ManagedUsersListProps) {
   const { t } = useTranslation()
+  const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
+    undefined
+  )
+
   return (
-    <ul className="list-unstyled structured-list managed-users-list">
-      <li className="container-fluid">
-        <Row id="managed-users-list-headers">
-          <Col xs={6}>
-            <label htmlFor="select-all" className="sr-only">
-              {t('select_all')}
-            </label>
-            <input
-              className="select-all"
-              id="select-all"
-              type="checkbox"
-              onChange={handleSelectAllClick}
-              checked={selectedUsers.length === users.length}
-            />
-            <span className="header">{t('email')}</span>
-          </Col>
-          <Col xs={2}>
-            <span className="header">{t('name')}</span>
-          </Col>
-          <Col xs={2}>
-            <Tooltip
-              id="last-active-tooltip"
-              description={t('last_active_description')}
-              overlayProps={{
-                placement: 'left',
-              }}
-            >
-              <span className="header">
-                {t('last_active')}
-                <sup>(?)</sup>
-              </span>
-            </Tooltip>
-          </Col>
-          <Col xs={2}>
-            <span className="header">{t('security')}</span>
-          </Col>
-        </Row>
-      </li>
-      {users.length === 0 && (
-        <li>
-          <Row>
-            <Col md={12} className="text-centered">
-              <small>{t('no_members')}</small>
+    <div>
+      <ul className="list-unstyled structured-list managed-users-list">
+        <li className="container-fluid">
+          <Row id="managed-users-list-headers">
+            <Col xs={6}>
+              <label htmlFor="select-all" className="sr-only">
+                {t('select_all')}
+              </label>
+              <input
+                className="select-all"
+                id="select-all"
+                type="checkbox"
+                onChange={handleSelectAllClick}
+                checked={selectedUsers.length === users.length}
+              />
+              <span className="header">{t('email')}</span>
+            </Col>
+            <Col xs={2}>
+              <span className="header">{t('name')}</span>
+            </Col>
+            <Col xs={2}>
+              <Tooltip
+                id="last-active-tooltip"
+                description={t('last_active_description')}
+                overlayProps={{
+                  placement: 'left',
+                }}
+              >
+                <span className="header">
+                  {t('last_active')}
+                  <sup>(?)</sup>
+                </span>
+              </Tooltip>
+            </Col>
+            <Col xs={2}>
+              <span className="header">{t('security')}</span>
             </Col>
           </Row>
         </li>
-      )}
-      {users.map((user: any) => (
-        <ManagedUserRow
-          key={user.email}
-          user={user}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          selected={selectedUsers.includes(user)}
+        {users.length === 0 && (
+          <li>
+            <Row>
+              <Col md={12} className="text-centered">
+                <small>{t('no_members')}</small>
+              </Col>
+            </Row>
+          </li>
+        )}
+        {users.map((user: any) => (
+          <ManagedUserRow
+            key={user.email}
+            user={user}
+            selectUser={selectUser}
+            unselectUser={unselectUser}
+            selected={selectedUsers.includes(user)}
+            openOffboardingModalForUser={setUserToOffboard}
+          />
+        ))}
+      </ul>
+      {userToOffboard && (
+        <OffboardManagedUserModal
+          user={userToOffboard}
+          groupId={groupId}
+          allMembers={users}
+          onClose={() => setUserToOffboard(undefined)}
         />
-      ))}
-    </ul>
+      )}
+    </div>
   )
 }

+ 164 - 0
services/web/frontend/js/features/group-management/components/managed-users/offboard-managed-user-modal.tsx

@@ -0,0 +1,164 @@
+import { User } from '../../../../../../types/group-management/user'
+import {
+  Alert,
+  Button,
+  ControlLabel,
+  Form,
+  FormControl,
+  FormGroup,
+  Modal,
+} from 'react-bootstrap'
+import AccessibleModal from '../../../../shared/components/accessible-modal'
+import Icon from '../../../../shared/components/icon'
+import { useState } from 'react'
+import useAsync from '../../../../shared/hooks/use-async'
+import { useTranslation } from 'react-i18next'
+import { useLocation } from '../../../../shared/hooks/use-location'
+import { FetchError, postJSON } from '../../../../infrastructure/fetch-json'
+
+type OffboardManagedUserModalProps = {
+  user: User
+  allMembers: User[]
+  groupId: string
+  onClose: () => void
+}
+
+export default function OffboardManagedUserModal({
+  user,
+  allMembers,
+  groupId,
+  onClose,
+}: OffboardManagedUserModalProps) {
+  const { t } = useTranslation()
+  const location = useLocation()
+
+  const [selectedRecipientId, setSelectedRecipientId] = useState<string>()
+  const [suppliedEmail, setSuppliedEmail] = useState<string>()
+  const [error, setError] = useState<string>()
+
+  const { isLoading, isSuccess, runAsync } = useAsync()
+
+  const otherMembers = allMembers.filter(u => u._id !== user._id && !u.invite)
+  const userFullName = user.last_name
+    ? `${user.first_name || ''} ${user.last_name || ''}`
+    : user.first_name
+
+  const shouldEnableDeleteUserButton =
+    suppliedEmail === user.email && !!selectedRecipientId
+
+  const handleDeleteUserSubmit = (event: any) => {
+    event.preventDefault()
+    runAsync(
+      postJSON(`/manage/groups/${groupId}/offboardManagedUser/${user._id}`, {
+        body: {
+          verificationEmail: suppliedEmail,
+          transferProjectsToUserId: selectedRecipientId,
+        },
+      })
+        .then(() => {
+          location.reload()
+        })
+        .catch(err => {
+          console.error(err)
+          setError(
+            err instanceof FetchError ? err.getUserFacingMessage() : err.message
+          )
+        })
+    )
+  }
+
+  return (
+    <AccessibleModal id={`delete-user-modal-${user._id}`} show onHide={onClose}>
+      <Form id="delete-user-form" onSubmit={handleDeleteUserSubmit}>
+        <Modal.Header>
+          <Modal.Title>{t('delete_user')}</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <p>
+            {t('about_to_delete_user_preamble', {
+              userName: userFullName,
+              userEmail: user.email,
+            })}
+          </p>
+          <ul>
+            <li>{t('they_lose_access_to_account')}</li>
+            <li>{t('their_projects_will_be_transferred_to_another_user')}</li>
+            <li>{t('you_will_be_able_to_reassign_subscription')}</li>
+          </ul>
+          <p>
+            <span>{t('this_action_cannot_be_reversed')}</span>
+            &nbsp;
+            <a href="/learn/how-to/Managed_Users">
+              {t('learn_more_about_managed_users')}
+            </a>
+          </p>
+          <strong>{t('transfer_this_users_projects')}</strong>
+          <p>{t('transfer_this_users_projects_description')}</p>
+          <FormGroup>
+            <ControlLabel htmlFor="recipient-select-input">
+              {t('select_a_new_owner_for_projects')}
+            </ControlLabel>
+            <FormControl
+              id="recipient-select-input"
+              className="form-control"
+              componentClass="select"
+              aria-label={t('select_user')}
+              required
+              placeholder={t('choose_from_group_members')}
+              value={selectedRecipientId || ''}
+              onChange={(e: React.ChangeEvent<HTMLFormElement & FormControl>) =>
+                setSelectedRecipientId(e.target.value)
+              }
+            >
+              <option hidden disabled value="">
+                {t('choose_from_group_members')}
+              </option>
+              {otherMembers.map(member => (
+                <option value={member._id} key={member.email}>
+                  {member.email}
+                </option>
+              ))}
+            </FormControl>
+          </FormGroup>
+          <p>
+            <span>{t('all_projects_will_be_transferred_immediately')}</span>
+          </p>
+          <FormGroup>
+            <ControlLabel htmlFor="supplied-email-input">
+              {t('confirm_delete_user_type_email_address', {
+                userName: userFullName,
+              })}
+            </ControlLabel>
+            <FormControl
+              id="supplied-email-input"
+              type="email"
+              aria-label={t('email')}
+              onChange={(e: React.ChangeEvent<HTMLFormElement & FormControl>) =>
+                setSuppliedEmail(e.target.value)
+              }
+            />
+          </FormGroup>
+          {error && <Alert bsStyle="danger">{error}</Alert>}
+        </Modal.Body>
+        <Modal.Footer>
+          <FormGroup>
+            <Button onClick={onClose}>{t('cancel')}</Button>
+            <Button
+              type="submit"
+              bsStyle="danger"
+              disabled={isLoading || isSuccess || !shouldEnableDeleteUserButton}
+            >
+              {isLoading ? (
+                <>
+                  <Icon type="refresh" fw spin /> {t('deleting')}…
+                </>
+              ) : (
+                t('delete_user')
+              )}
+            </Button>
+          </FormGroup>
+        </Modal.Footer>
+      </Form>
+    </AccessibleModal>
+  )
+}

+ 5 - 2
services/web/frontend/stylesheets/components/group-members.less

@@ -17,19 +17,22 @@
   .security-state-not-managed {
     color: @red;
   }
+  .managed-user-row {
+    overflow-wrap: break-word;
+  }
   .managed-user-actions {
     button.dropdown-toggle {
       color: @text-color;
       padding-left: 1em;
       padding-right: 1em;
     }
-    li > a {
+    li > button {
       &:hover {
         background-color: @gray-lightest;
       }
     }
     .delete-user-action {
-      a {
+      button {
         color: @red;
       }
     }

+ 12 - 0
services/web/locales/en.json

@@ -21,6 +21,7 @@
   "about_to_delete_tag": "You are about to delete the following tag (any projects in them will not be deleted):",
   "about_to_delete_the_following_project": "You are about to delete the following project",
   "about_to_delete_the_following_projects": "You are about to delete the following projects",
+  "about_to_delete_user_preamble": "You’re about to delete __userName__ (__userEmail__). Doing this will mean:",
   "about_to_enable_managed_users": "You’re about to enable Managed Users for your organization. Once Managed Users is enabled, you can’t disable it.",
   "about_to_leave_projects": "You are about to leave the following projects:",
   "about_to_trash_projects": "You are about to trash the following projects:",
@@ -83,6 +84,7 @@
   "all_premium_features_including": "All premium features, including:",
   "all_prices_displayed_are_in_currency": "All prices displayed are in __recommendedCurrency__.",
   "all_projects": "All Projects",
+  "all_projects_will_be_transferred_immediately": "All projects will be transferred to the new owner immediately.",
   "all_templates": "All Templates",
   "already_have_sl_account": "Already have an __appName__ account?",
   "also": "Also",
@@ -214,6 +216,7 @@
   "checking_dropbox_status": "Checking Dropbox status",
   "checking_project_github_status": "Checking project status in GitHub",
   "choose_a_custom_color": "Choose a custom color",
+  "choose_from_group_members": "Choose from Group Members",
   "choose_your_plan": "Choose your plan",
   "city": "City",
   "clear_cached_files": "Clear cached files",
@@ -261,6 +264,7 @@
   "confirm": "Confirm",
   "confirm_affiliation": "Confirm Affiliation",
   "confirm_affiliation_to_relink_dropbox": "Please confirm you are still at the institution and on their license, or upgrade your account in order to relink your Dropbox account.",
+  "confirm_delete_user_type_email_address": "To confirm you want to delete __userName__ please type the email address associated with their account",
   "confirm_email": "Confirm Email",
   "confirm_new_password": "Confirm New Password",
   "confirm_primary_email_change": "Confirm primary email change",
@@ -861,6 +865,7 @@
   "learn_more": "Learn more",
   "learn_more_about_emails": "<0>Learn more</0> about managing your __appName__ emails.",
   "learn_more_about_link_sharing": "Learn more about Link Sharing",
+  "learn_more_about_managed_users": "Learn more about Managed Users.",
   "learn_more_lowercase": "learn more",
   "leave": "Leave",
   "leave_any_group_subscriptions": "Leave any group subscriptions other than the one that will be managing your account. <0>Leave them from the Subscription page.</0>",
@@ -1406,6 +1411,7 @@
   "see_what_has_been": "See what has been ",
   "select_a_file": "Select a File",
   "select_a_file_figure_modal": "Select a file",
+  "select_a_new_owner_for_projects": "Select a new owner for this user’s projects",
   "select_a_payment_method": "Select a payment method",
   "select_a_project": "Select a Project",
   "select_a_project_figure_modal": "Select a project",
@@ -1586,12 +1592,15 @@
   "the_supplied_parameters_were_invalid": "The link to open this content on Overleaf included some invalid parameters. If this keeps happening for links on a particular site, please report this to them.",
   "the_supplied_uri_is_invalid": "The link to open this content on Overleaf included an invalid URI. If this keeps happening for links on a particular site, please report this to them.",
   "the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "The width you choose here is based on the width of the text in your document. Alternatively, you can customize the image size directly in the LaTeX code.",
+  "their_projects_will_be_transferred_to_another_user": "Their projects will all be transferred to another user of your choice",
   "theme": "Theme",
   "then_x_price_per_month": "Then __price__ per month",
   "then_x_price_per_year": "Then __price__ per year",
   "there_are_lots_of_options_to_edit_and_customize_your_figures": "There are lots of options to edit and customize your figures, such as wrapping text around the figure, rotating the image, or including multiple images in a single figure. You’ll need to edit the LaTeX code to do this. <0>Find out how</0>",
   "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",
+  "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",
   "this_field_is_required": "This field is required",
@@ -1666,6 +1675,8 @@
   "transfer_management_of_your_account": "Transfer management of your Overleaf account",
   "transfer_management_of_your_account_to_x": "Transfer management of your Overleaf account to __groupName__",
   "transfer_management_resolve_following_issues": "To transfer the management of your account, you need to resolve the following issues:",
+  "transfer_this_users_projects": "Transfer this user’s projects",
+  "transfer_this_users_projects_description": "This user’s projects will be transferred to a new owner.",
   "transferring": "Transferring",
   "trash": "Trash",
   "trash_projects": "Trash Projects",
@@ -1837,6 +1848,7 @@
   "you_introed_small_number": " You’ve introduced <0>__numberOfPeople__</0> person to __appName__. Good job, but can you get some more?",
   "you_not_introed_anyone_to_sl": "You’ve not introduced anyone to __appName__ yet. Get sharing!",
   "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "<0>You will be able to contact us</0> any time to share your feedback",
+  "you_will_be_able_to_reassign_subscription": "You will be able to reassign their subscription membership to another person in your organization",
   "your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
   "your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
   "your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",

+ 13 - 2
services/web/test/frontend/features/group-management/components/managed-users/managed-user-dropdown-button.spec.tsx

@@ -1,4 +1,5 @@
 import ManagedUserDropdownButton from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button'
+import sinon from 'sinon'
 
 describe('ManagedUserDropdownButton', function () {
   describe('with managed user', function () {
@@ -17,7 +18,12 @@ describe('ManagedUserDropdownButton', function () {
     }
 
     beforeEach(function () {
-      cy.mount(<ManagedUserDropdownButton user={user} />)
+      cy.mount(
+        <ManagedUserDropdownButton
+          user={user}
+          openOffboardingModalForUser={sinon.stub()}
+        />
+      )
     })
 
     it('should render the button', function () {
@@ -47,7 +53,12 @@ describe('ManagedUserDropdownButton', function () {
     }
 
     beforeEach(function () {
-      cy.mount(<ManagedUserDropdownButton user={user} />)
+      cy.mount(
+        <ManagedUserDropdownButton
+          user={user}
+          openOffboardingModalForUser={sinon.stub()}
+        />
+      )
     })
 
     it('should render the button', function () {

+ 6 - 0
services/web/test/frontend/features/group-management/components/managed-users/managed-user-row.spec.tsx

@@ -30,6 +30,7 @@ describe('ManagedUserRow', function () {
           selectUser={selectUser}
           unselectUser={unselectUser}
           selected={selected}
+          openOffboardingModalForUser={sinon.stub()}
         />
       )
     })
@@ -79,6 +80,7 @@ describe('ManagedUserRow', function () {
           selectUser={selectUser}
           unselectUser={unselectUser}
           selected={selected}
+          openOffboardingModalForUser={sinon.stub()}
         />
       )
     })
@@ -115,6 +117,7 @@ describe('ManagedUserRow', function () {
           selectUser={selectUser}
           unselectUser={unselectUser}
           selected={selected}
+          openOffboardingModalForUser={sinon.stub()}
         />
       )
     })
@@ -152,6 +155,7 @@ describe('ManagedUserRow', function () {
           selectUser={selectUser}
           unselectUser={unselectUser}
           selected={selected}
+          openOffboardingModalForUser={sinon.stub()}
         />
       )
     })
@@ -189,6 +193,7 @@ describe('ManagedUserRow', function () {
           selectUser={selectUser}
           unselectUser={unselectUser}
           selected={selected}
+          openOffboardingModalForUser={sinon.stub()}
         />
       )
     })
@@ -231,6 +236,7 @@ describe('ManagedUserRow', function () {
           selectUser={selectUser}
           unselectUser={unselectUser}
           selected={selected}
+          openOffboardingModalForUser={sinon.stub()}
         />
       )
     })

+ 3 - 0
services/web/test/frontend/features/group-management/components/managed-users/managed-users-list.spec.tsx

@@ -2,6 +2,7 @@ import ManagedUsersList from '../../../../../../frontend/js/features/group-manag
 import { User } from '../../../../../../types/group-management/user'
 
 describe('ManagedUsersList', function () {
+  const groupId = 'somegroup'
   describe('with users', function () {
     const users: User[] = [
       {
@@ -38,6 +39,7 @@ describe('ManagedUsersList', function () {
           handleSelectAllClick={handleSelectAllClick}
           selectUser={selectUser}
           unselectUser={unselectUser}
+          groupId={groupId}
         />
       )
     })
@@ -83,6 +85,7 @@ describe('ManagedUsersList', function () {
           handleSelectAllClick={handleSelectAllClick}
           selectUser={selectUser}
           unselectUser={unselectUser}
+          groupId={groupId}
         />
       )
     })

+ 107 - 0
services/web/test/frontend/features/group-management/components/managed-users/offboard-managed-user-modal.spec.tsx

@@ -0,0 +1,107 @@
+import OffboardManagedUserModal from '../../../../../../frontend/js/features/group-management/components/managed-users/offboard-managed-user-modal'
+import sinon from 'sinon'
+
+describe('OffboardManagedUserModal', 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,
+    }
+    const otherUser = {
+      _id: 'other-user',
+      email: 'other.user@example.com',
+      first_name: 'Other',
+      last_name: 'User',
+      invite: false,
+      last_active_at: new Date(),
+      enrollment: {
+        managedBy: `${groupId}`,
+        enrolledAt: new Date(),
+      },
+      isEntityAdmin: undefined,
+    }
+    const allMembers = [user, otherUser]
+
+    beforeEach(function () {
+      cy.mount(
+        <OffboardManagedUserModal
+          user={user}
+          allMembers={allMembers}
+          groupId={groupId}
+          onClose={sinon.stub()}
+        />
+      )
+    })
+
+    it('should render the modal', function () {
+      cy.get('#delete-user-form').should('exist')
+    })
+
+    it('should disable the button if a recipient is not selected', function () {
+      // Button should be disabled initially
+      cy.get('button[type="submit"]').should('be.disabled')
+
+      // Not selecting a recipient...
+
+      // Fill in the email input
+      cy.get('#supplied-email-input').type(user.email)
+
+      // Button still disabled
+      cy.get('button[type="submit"]').should('be.disabled')
+    })
+
+    it('should disable the button if the email is not filled in', function () {
+      // Button should be disabled initially
+      cy.get('button[type="submit"]').should('be.disabled')
+
+      // Select a recipient
+      cy.get('#recipient-select-input').select('other.user@example.com')
+
+      // Not filling in the email...
+
+      // Button still disabled
+      cy.get('button[type="submit"]').should('be.disabled')
+    })
+
+    it('should disable the button if the email does not match the user', function () {
+      // Button should be disabled initially
+      cy.get('button[type="submit"]').should('be.disabled')
+
+      // Select a recipient
+      cy.get('#recipient-select-input').select('other.user@example.com')
+
+      // Fill in the email input, with the wrong email address
+      cy.get('#supplied-email-input').type('totally.wrong@example.com')
+
+      // Button still disabled
+      cy.get('button[type="submit"]').should('be.disabled')
+    })
+
+    it('should fill out the form, and enable the delete button', function () {
+      // Button should be disabled initially
+      cy.get('button[type="submit"]').should('be.disabled')
+
+      // Select a recipient
+      cy.get('#recipient-select-input').select('other.user@example.com')
+
+      // Button still disabled
+      cy.get('button[type="submit"]').should('be.disabled')
+
+      // Fill in the email input
+      cy.get('#supplied-email-input').type(user.email)
+
+      // Button should be enabled now
+      cy.get('button[type="submit"]').should('not.be.disabled')
+    })
+  })
+})

+ 9 - 0
services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js

@@ -195,6 +195,15 @@ describe('OwnershipTransferHandler', function () {
       )
     })
 
+    it('should not send an email notification with the skipEmails option', async function () {
+      await this.handler.promises.transferOwnership(
+        this.project._id,
+        this.collaborator._id,
+        { skipEmails: true }
+      )
+      expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
+    })
+
     it('should track the change in BigQuery', async function () {
       const sessionUserId = ObjectId()
       await this.handler.promises.transferOwnership(

+ 23 - 0
services/web/test/unit/src/Tags/TagsHandlerTests.js

@@ -67,6 +67,29 @@ describe('TagsHandler', function () {
       })
     })
 
+    describe('when truncate=true, and tag is too long', function () {
+      it('should truncate the tag name', function (done) {
+        // Expect the tag to end up with this truncated name
+        this.tag.name = 'a comically long tag that will be truncated intern'
+        this.TagMock.expects('create')
+          .withArgs(this.tag)
+          .once()
+          .yields(null, this.tag)
+        this.TagsHandler.createTag(
+          this.tag.user_id,
+          // Pass this too-long name
+          'a comically long tag that will be truncated internally and not throw an error',
+          this.tag.color,
+          { truncate: true },
+          (err, resultTag) => {
+            expect(err).to.not.exist
+            expect(resultTag.name).to.equal(this.tag.name)
+            done()
+          }
+        )
+      })
+    })
+
     describe('when tag is too long', function () {
       it('should throw an error', function (done) {
         this.TagsHandler.createTag(