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

Merge pull request #13483 from overleaf/jk-managed-users-group-management-ui

[web] Managed Users: Update Group Member Management UI

GitOrigin-RevId: 6896951927f0e3220db59dda208d7cfe9c6c309e
June Kelly 3 лет назад
Родитель
Сommit
a14e2aecfb
24 измененных файлов с 1237 добавлено и 63 удалено
  1. 7 0
      services/web/app/src/Features/UserMembership/UserMembershipController.js
  2. 15 1
      services/web/app/src/Features/UserMembership/UserMembershipHandler.js
  3. 7 0
      services/web/app/src/Features/UserMembership/UserMembershipViewModel.js
  4. 1 0
      services/web/app/views/user_membership/group-members-react.pug
  5. 4 0
      services/web/config/settings.defaults.js
  6. 7 0
      services/web/frontend/extracted-translations.json
  7. 82 0
      services/web/frontend/js/features/group-management/components/group-members-list.tsx
  8. 32 61
      services/web/frontend/js/features/group-management/components/group-members.tsx
  9. 40 0
      services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx
  10. 107 0
      services/web/frontend/js/features/group-management/components/managed-users/managed-user-row.tsx
  11. 56 0
      services/web/frontend/js/features/group-management/components/managed-users/managed-user-status.tsx
  12. 82 0
      services/web/frontend/js/features/group-management/components/managed-users/managed-users-list.tsx
  13. 1 0
      services/web/frontend/stylesheets/_style_includes.less
  14. 37 0
      services/web/frontend/stylesheets/components/group-members.less
  15. 6 0
      services/web/locales/en.json
  16. 1 1
      services/web/test/frontend/features/group-management/components/group-members.spec.tsx
  17. 224 0
      services/web/test/frontend/features/group-management/components/managed-users/managed-group-members.spec.tsx
  18. 63 0
      services/web/test/frontend/features/group-management/components/managed-users/managed-user-dropdown-button.spec.tsx
  19. 246 0
      services/web/test/frontend/features/group-management/components/managed-users/managed-user-row.spec.tsx
  20. 86 0
      services/web/test/frontend/features/group-management/components/managed-users/managed-user-status.spec.tsx
  21. 97 0
      services/web/test/frontend/features/group-management/components/managed-users/managed-users-list.spec.tsx
  22. 21 0
      services/web/test/unit/src/UserMembership/UserMembershipControllerTests.js
  23. 8 0
      services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js
  24. 7 0
      services/web/types/group-management/user.ts

+ 7 - 0
services/web/app/src/Features/UserMembership/UserMembershipController.js

@@ -17,9 +17,15 @@ const EmailHelper = require('../Helpers/EmailHelper')
 const { csvAttachment } = require('../../infrastructure/Response')
 const { UserIsManagerError } = require('./UserMembershipErrors')
 const CSVParser = require('json2csv').Parser
+const Settings = require('@overleaf/settings')
+
+function isManagedUsersActiveOnGroup(entity) {
+  return !!(Settings.managedUsers?.enabled && entity.groupPolicy)
+}
 
 async function manageGroupMembers(req, res, next) {
   const { entity, entityConfig } = req
+  const managedUsersActive = isManagedUsersActiveOnGroup(entity)
   return entity.fetchV1Data(function (error, entity) {
     if (error != null) {
       return next(error)
@@ -42,6 +48,7 @@ async function manageGroupMembers(req, res, next) {
           groupId: entityPrimaryKey,
           users,
           groupSize: entity.membersLimit,
+          managedUsersActive,
         })
       }
     )

+ 15 - 1
services/web/app/src/Features/UserMembership/UserMembershipHandler.js

@@ -106,7 +106,21 @@ function getPopulatedListOfMembers(entity, attributes, callback) {
     }
   }
 
-  async.map(userObjects, UserMembershipViewModel.buildAsync, callback)
+  async.map(userObjects, UserMembershipViewModel.buildAsync, (err, users) => {
+    if (err) {
+      return callback(err)
+    }
+    for (const user of users) {
+      if (
+        user?._id &&
+        entity?.admin_id &&
+        user._id.toString() === entity.admin_id.toString()
+      ) {
+        user.isEntityAdmin = true
+      }
+    }
+    callback(null, users)
+  })
 }
 
 function addUserToEntity(entity, attribute, user, callback) {

+ 7 - 0
services/web/app/src/Features/UserMembership/UserMembershipViewModel.js

@@ -39,6 +39,7 @@ module.exports = UserMembershipViewModel = {
       last_name: 1,
       lastLoggedIn: 1,
       lastActive: 1,
+      enrollment: 1,
     }
     return UserGetter.getUser(userId, projection, function (error, user) {
       if (error != null || user == null) {
@@ -61,6 +62,12 @@ function buildUserViewModel(user, isInvite) {
     last_active_at: user.lastActive || user.lastLoggedIn || null,
     last_logged_in_at: user.lastLoggedIn || null,
     invite: isInvite,
+    enrollment: user.enrollment
+      ? {
+          managedBy: user.enrollment.managedBy,
+          enrolledAt: user.enrollment.enrolledAt,
+        }
+      : undefined,
   }
 }
 

+ 1 - 0
services/web/app/views/user_membership/group-members-react.pug

@@ -8,6 +8,7 @@ block append meta
 	meta(name="ol-groupId", data-type="string", content=groupId)
 	meta(name="ol-groupName", data-type="string", content=name)
 	meta(name="ol-groupSize", data-type="json", content=groupSize)
+	meta(name="ol-managedUsersActive", data-type="boolean", content=managedUsersActive)
 
 block content
 	main.content.content-alt#subscription-manage-group-root

+ 4 - 0
services/web/config/settings.defaults.js

@@ -826,6 +826,10 @@ module.exports = {
 
   // ID of the IEEE brand in the rails app
   ieeeBrandId: intFromEnv('IEEE_BRAND_ID', 15),
+
+  managedUsers: {
+    enabled: false,
+  },
 }
 
 module.exports.mergeWith = function (overrides) {

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

@@ -198,6 +198,7 @@
   "delete_projects": "",
   "delete_tag": "",
   "delete_token": "",
+  "delete_user": "",
   "delete_your_account": "",
   "deleted_at": "",
   "deleted_by_on": "",
@@ -395,6 +396,7 @@
   "go_to_code_location_in_pdf": "",
   "go_to_pdf_location_in_code": "",
   "go_to_settings": "",
+  "group_admin": "",
   "group_plan_tooltip": "",
   "group_plan_with_name_tooltip": "",
   "group_subscription": "",
@@ -577,6 +579,7 @@
   "manage_publisher_managers": "",
   "manage_sessions": "",
   "manage_subscription": "",
+  "managed": "",
   "managed_users": "",
   "managed_users_explanation": "",
   "managers_management": "",
@@ -624,6 +627,7 @@
   "new_to_latex_look_at": "",
   "newsletter": "",
   "next_payment_of_x_collectected_on_y": "",
+  "no_actions": "",
   "no_comments": "",
   "no_existing_password": "",
   "no_folder": "",
@@ -646,6 +650,7 @@
   "normal": "",
   "normally_x_price_per_month": "",
   "normally_x_price_per_year": "",
+  "not_managed": "",
   "notification_project_invite_accepted_message": "",
   "notification_project_invite_message": "",
   "number_of_users": "",
@@ -689,6 +694,7 @@
   "pdf_viewer": "",
   "pdf_viewer_error": "",
   "pending_additional_licenses": "",
+  "pending_invite": "",
   "percent_discount_for_groups": "",
   "plan": "",
   "plan_tooltip": "",
@@ -848,6 +854,7 @@
   "search_search_for": "",
   "search_whole_word": "",
   "search_within_selection": "",
+  "security": "",
   "select_a_file": "",
   "select_a_file_figure_modal": "",
   "select_a_payment_method": "",

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

@@ -0,0 +1,82 @@
+import { Col, Row } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { User } from '../../../../../types/group-management/user'
+import Tooltip from '../../../shared/components/tooltip'
+import GroupMemberRow from './group-member-row'
+
+type GroupMembersListProps = {
+  handleSelectAllClick: (e: any) => void
+  selectedUsers: User[]
+  users: User[]
+  selectUser: (user: User) => void
+  unselectUser: (user: User) => void
+}
+
+export default function GroupMembersList({
+  handleSelectAllClick,
+  selectedUsers,
+  users,
+  selectUser,
+  unselectUser,
+}: GroupMembersListProps) {
+  const { t } = useTranslation()
+  return (
+    <ul className="list-unstyled structured-list">
+      <li className="container-fluid">
+        <Row>
+          <Col xs={4}>
+            <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={4}>
+            <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('accepted_invite')}</span>
+          </Col>
+        </Row>
+      </li>
+      {users.length === 0 && (
+        <li>
+          <Row>
+            <Col md={12} className="text-centered">
+              <small>{t('no_members')}</small>
+            </Col>
+          </Row>
+        </li>
+      )}
+      {users.map((user: any) => (
+        <GroupMemberRow
+          key={user.email}
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selectedUsers.includes(user)}
+        />
+      ))}
+    </ul>
+  )
+}

+ 32 - 61
services/web/frontend/js/features/group-management/components/group-members.tsx

@@ -8,13 +8,13 @@ import {
   postJSON,
 } from '../../../infrastructure/fetch-json'
 import MaterialIcon from '../../../shared/components/material-icon'
-import Tooltip from '../../../shared/components/tooltip'
 import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
 import getMeta from '../../../utils/meta'
 import { parseEmails } from '../utils/emails'
 import ErrorAlert, { APIError } from './error-alert'
-import GroupMemberRow from './group-member-row'
 import useUserSelection from '../hooks/use-user-selection'
+import ManagedUsersList from './managed-users/managed-users-list'
+import GroupMembersList from './group-members-list'
 
 export default function GroupMembers() {
   const { isReady } = useWaitForI18n()
@@ -39,6 +39,7 @@ export default function GroupMembers() {
   const groupId: string = getMeta('ol-groupId')
   const groupName: string = getMeta('ol-groupName')
   const groupSize: number = getMeta('ol-groupSize')
+  const managedUsersActive: any = getMeta('ol-managedUsersActive')
 
   const paths = useMemo(
     () => ({
@@ -90,6 +91,9 @@ export default function GroupMembers() {
       setRemoveMemberError(undefined)
       ;(async () => {
         for (const user of selectedUsers) {
+          if (user?.enrollment?.managedBy) {
+            continue
+          }
           let url
           if (paths.removeInvite && user.invite && user._id == null) {
             url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
@@ -142,6 +146,13 @@ export default function GroupMembers() {
     return null
   }
 
+  const shouldShowRemoveUsersButton = () => {
+    return (
+      selectedUsers.length > 0 &&
+      !selectedUsers.find((u: User) => !!u?.enrollment?.managedBy)
+    )
+  }
+
   return (
     <div className="container">
       <Row>
@@ -173,7 +184,7 @@ export default function GroupMembers() {
                   </Button>
                 ) : (
                   <>
-                    {selectedUsers.length > 0 && (
+                    {shouldShowRemoveUsersButton() && (
                       <Button bsStyle="danger" onClick={removeMembers}>
                         {t('remove_from_group')}
                       </Button>
@@ -185,67 +196,27 @@ export default function GroupMembers() {
             </div>
             <div className="row-spaced-small">
               <ErrorAlert error={removeMemberError} />
-              <ul className="list-unstyled structured-list">
-                <li className="container-fluid">
-                  <Row>
-                    <Col xs={4}>
-                      <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={4}>
-                      <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('accepted_invite')}</span>
-                    </Col>
-                  </Row>
-                </li>
-                {users.length === 0 && (
-                  <li>
-                    <Row>
-                      <Col md={12} className="text-centered">
-                        <small>{t('no_members')}</small>
-                      </Col>
-                    </Row>
-                  </li>
-                )}
-                {users.map((user: any) => (
-                  <GroupMemberRow
-                    key={user.email}
-                    user={user}
-                    selectUser={selectUser}
-                    unselectUser={unselectUser}
-                    selected={selectedUsers.includes(user)}
-                  />
-                ))}
-              </ul>
+              {managedUsersActive ? (
+                <ManagedUsersList
+                  handleSelectAllClick={handleSelectAllClick}
+                  selectedUsers={selectedUsers}
+                  users={users}
+                  selectUser={selectUser}
+                  unselectUser={unselectUser}
+                />
+              ) : (
+                <GroupMembersList
+                  handleSelectAllClick={handleSelectAllClick}
+                  selectedUsers={selectedUsers}
+                  users={users}
+                  selectUser={selectUser}
+                  unselectUser={unselectUser}
+                />
+              )}
             </div>
             <hr />
             {users.length < groupSize && (
-              <div>
+              <div className="add-more-members-form">
                 <p className="small">{t('add_more_members')}</p>
                 <ErrorAlert error={inviteError} />
                 <Form horizontal onSubmit={addMembers} className="form">

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

@@ -0,0 +1,40 @@
+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'
+
+type ManagedUserDropdownButtonProps = {
+  user: User
+}
+
+export default function ManagedUserDropdownButton({
+  user,
+}: ManagedUserDropdownButtonProps) {
+  const { t } = useTranslation()
+  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>
+        ) : (
+          <>
+            <MenuItem className="no-actions-available">
+              <span className="text-muted">{t('no_actions')}</span>
+            </MenuItem>
+          </>
+        )}
+      </Dropdown.Menu>
+    </ControlledDropdown>
+  )
+}

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

@@ -0,0 +1,107 @@
+import moment from 'moment'
+import { useCallback } from 'react'
+import { Col, Row } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { User } from '../../../../../../types/group-management/user'
+import Badge from '../../../../shared/components/badge'
+import ManagedUserDropdownButton from './managed-user-dropdown-button'
+import Tooltip from '../../../../shared/components/tooltip'
+import ManagedUserStatus from './managed-user-status'
+
+type ManagedUserRowProps = {
+  user: User
+  selectUser: (user: User) => void
+  unselectUser: (user: User) => void
+  selected: boolean
+}
+
+export default function ManagedUserRow({
+  user,
+  selectUser,
+  unselectUser,
+  selected,
+}: ManagedUserRowProps) {
+  const { t } = useTranslation()
+
+  const handleSelectUser = useCallback(
+    (event, user) => {
+      if (event.target.checked) {
+        selectUser(user)
+      } else {
+        unselectUser(user)
+      }
+    },
+    [selectUser, unselectUser]
+  )
+
+  return (
+    <li
+      key={`user-${user.email}`}
+      className={`managed-user-row ${user.invite ? 'text-muted' : ''}`}
+    >
+      <Row>
+        <Col xs={6}>
+          <label htmlFor={`select-user-${user.email}`} className="sr-only">
+            {t('select_user')}
+          </label>
+          <input
+            className="select-item"
+            id={`select-user-${user.email}`}
+            type="checkbox"
+            checked={selected}
+            onChange={e => handleSelectUser(e, user)}
+          />
+          <span>
+            {user.email}
+            {user.invite ? (
+              <span>
+                &nbsp;
+                <Tooltip
+                  id={`pending-invite-symbol-${user._id}`}
+                  description={t('pending_invite')}
+                >
+                  <Badge aria-label={t('pending_invite')}>
+                    {t('pending_invite')}
+                  </Badge>
+                </Tooltip>
+              </span>
+            ) : (
+              ''
+            )}
+            {user.isEntityAdmin && (
+              <span>
+                &nbsp;
+                <Tooltip
+                  id={`group-admin-symbol-${user._id}`}
+                  description={t('group_admin')}
+                >
+                  <i
+                    className="fa fa-user-circle-o"
+                    aria-hidden="true"
+                    aria-label={t('group_admin')}
+                  />
+                </Tooltip>
+              </span>
+            )}
+          </span>
+        </Col>
+        <Col xs={2}>
+          {user.first_name} {user.last_name}
+        </Col>
+        <Col xs={2}>
+          {user.last_active_at
+            ? moment(user.last_active_at).format('Do MMM YYYY')
+            : 'N/A'}
+        </Col>
+        <Col xs={2}>
+          <span className="pull-right">
+            <ManagedUserStatus user={user} />
+            <span className="managed-user-actions">
+              <ManagedUserDropdownButton user={user} />
+            </span>
+          </span>
+        </Col>
+      </Row>
+    </li>
+  )
+}

+ 56 - 0
services/web/frontend/js/features/group-management/components/managed-users/managed-user-status.tsx

@@ -0,0 +1,56 @@
+import { useTranslation } from 'react-i18next'
+import { User } from '../../../../../../types/group-management/user'
+
+type ManagedUserStatusProps = {
+  user: User
+}
+export default function ManagedUserStatus({ user }: ManagedUserStatusProps) {
+  const { t } = useTranslation()
+  return (
+    <span>
+      {user.isEntityAdmin ? (
+        <>
+          <span className="security-state-group-admin" />
+        </>
+      ) : (
+        <>
+          {user.invite ? (
+            <span className="security-state-invite-pending">
+              <i
+                className="fa fa-clock-o"
+                aria-hidden="true"
+                aria-label={t('pending_invite')}
+              />
+              &nbsp;
+              {t('managed')}
+            </span>
+          ) : (
+            <>
+              {user.enrollment?.managedBy ? (
+                <span className="security-state-managed">
+                  <i
+                    className="fa fa-check"
+                    aria-hidden="true"
+                    aria-label={t('managed')}
+                  />
+                  &nbsp;
+                  {t('managed')}
+                </span>
+              ) : (
+                <span className="security-state-not-managed">
+                  <i
+                    className="fa fa-times"
+                    aria-hidden="true"
+                    aria-label={t('not_managed')}
+                  />
+                  &nbsp;
+                  {t('managed')}
+                </span>
+              )}
+            </>
+          )}
+        </>
+      )}
+    </span>
+  )
+}

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

@@ -0,0 +1,82 @@
+import { Col, Row } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { User } from '../../../../../../types/group-management/user'
+import Tooltip from '../../../../shared/components/tooltip'
+import ManagedUserRow from './managed-user-row'
+
+type ManagedUsersListProps = {
+  handleSelectAllClick: (e: any) => void
+  selectedUsers: User[]
+  users: User[]
+  selectUser: (user: User) => void
+  unselectUser: (user: User) => void
+}
+
+export default function ManagedUsersList({
+  handleSelectAllClick,
+  selectedUsers,
+  users,
+  selectUser,
+  unselectUser,
+}: ManagedUsersListProps) {
+  const { t } = useTranslation()
+  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>
+            </Col>
+          </Row>
+        </li>
+      )}
+      {users.map((user: any) => (
+        <ManagedUserRow
+          key={user.email}
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selectedUsers.includes(user)}
+        />
+      ))}
+    </ul>
+  )
+}

+ 1 - 0
services/web/frontend/stylesheets/_style_includes.less

@@ -63,6 +63,7 @@
 @import 'components/beta-badges.less';
 @import 'components/divider.less';
 @import 'components/split-menu.less';
+@import 'components/group-members.less';
 
 // Components w/ JavaScript
 @import 'components/modals.less';

+ 37 - 0
services/web/frontend/stylesheets/components/group-members.less

@@ -0,0 +1,37 @@
+/* Styles for group-subscription members view */
+
+.structured-list.managed-users-list {
+  /* Override scrolling behaviour on structured-list */
+  overflow: initial;
+  overflow-y: initial;
+  overflow-x: initial;
+}
+
+.managed-users-list {
+  .security-state-invite-pending {
+    color: @text-muted;
+  }
+  .security-state-managed {
+    color: @green;
+  }
+  .security-state-not-managed {
+    color: @red;
+  }
+  .managed-user-actions {
+    button.dropdown-toggle {
+      color: @text-color;
+      padding-left: 1em;
+      padding-right: 1em;
+    }
+    li > a {
+      &:hover {
+        background-color: @gray-lightest;
+      }
+    }
+    .delete-user-action {
+      a {
+        color: @red;
+      }
+    }
+  }
+}

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

@@ -340,6 +340,7 @@
   "delete_projects": "Delete Projects",
   "delete_tag": "Delete Tag",
   "delete_token": "Delete token",
+  "delete_user": "Delete User",
   "delete_your_account": "Delete your account",
   "deleted_at": "Deleted At",
   "deleted_by_on": "Deleted by __name__ on __date__",
@@ -656,6 +657,7 @@
   "go_to_code_location_in_pdf": "Go to code location in PDF",
   "go_to_pdf_location_in_code": "Go to PDF location in code (Tip: double click on the PDF for best results)",
   "go_to_settings": "Go to settings",
+  "group_admin": "Group admin",
   "group_admins_get_access_to": "Group admins get access to",
   "group_admins_get_access_to_info": "Special features available only on group plans.",
   "group_full": "This group is already full",
@@ -950,6 +952,7 @@
   "manage_publisher_managers": "Manage publisher managers",
   "manage_sessions": "Manage Your Sessions",
   "manage_subscription": "Manage Subscription",
+  "managed": "Managed",
   "managed_users": "Managed Users",
   "managed_users_explanation": "Managed Users ensures you stay in control of your organization’s projects and who owns them. <0>Read more about Managed Users.</0>",
   "managers_cannot_remove_admin": "Admins cannot be removed",
@@ -1026,6 +1029,7 @@
   "next_payment_of_x_collectected_on_y": "The next payment of <0>__paymentAmmount__</0> will be collected on <1>__collectionDate__</1>.",
   "nl": "Dutch",
   "no": "Norwegian",
+  "no_actions": "No actions",
   "no_comments": "No comments",
   "no_complicated_latex_install": "No complicated LaTeX installation",
   "no_existing_password": "Please use the password reset form to set your password",
@@ -1058,6 +1062,7 @@
   "normally_x_price_per_year": "Normally __price__ per year",
   "not_a_student_question": "Not a student?",
   "not_found_error_from_the_supplied_url": "The link to open this content on Overleaf pointed to a file that could not be found. If this keeps happening for links on a particular site, please report this to them.",
+  "not_managed": "Not managed",
   "not_now": "Not now",
   "not_registered": "Not registered",
   "note_experiments_under_development": "<0>Please note</0> that experiments in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a paid plan</0>",
@@ -1139,6 +1144,7 @@
   "pdf_viewer_error": "There was a problem displaying the PDF for this project.",
   "pending": "Pending",
   "pending_additional_licenses": "Your subscription is changing to include <0>__pendingAdditionalLicenses__</0> additional license(s) for a total of <1>__pendingTotalLicenses__</1> licenses.",
+  "pending_invite": "Pending invite",
   "per_month": "per month",
   "per_year": "per year",
   "percent_discount_for_groups": "__appName__ offers a __percent__% educational discount for groups of __size__ or more.",

+ 1 - 1
services/web/test/frontend/features/group-management/components/group-members.spec.tsx

@@ -24,7 +24,7 @@ const PATHS = {
   exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
 }
 
-describe('group members', function () {
+describe('group members, without managed users', function () {
   beforeEach(function () {
     cy.window().then(win => {
       win.metaAttributesCache = new Map()

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

@@ -0,0 +1,224 @@
+import GroupMembers from '../../../../../../frontend/js/features/group-management/components/group-members'
+
+const GROUP_ID = '777fff777fff'
+const JOHN_DOE = {
+  _id: 'abc123def456',
+  first_name: 'John',
+  last_name: 'Doe',
+  email: 'john.doe@test.com',
+  last_active_at: new Date('2023-01-15'),
+  invite: true,
+}
+const BOBBY_LAPOINTE = {
+  _id: 'bcd234efa567',
+  first_name: 'Bobby',
+  last_name: 'Lapointe',
+  email: 'bobby.lapointe@test.com',
+  last_active_at: new Date('2023-01-02'),
+  invite: false,
+}
+const CLAIRE_JENNINGS = {
+  _id: 'defabc231453',
+  first_name: 'Claire',
+  last_name: 'Jennings',
+  email: 'claire.jennings@test.com',
+  last_active_at: new Date('2023-01-03'),
+  invite: false,
+  enrollment: {
+    managedBy: GROUP_ID,
+    enrolledAt: new Date('2023-01-03'),
+  },
+}
+const PATHS = {
+  addMember: `/manage/groups/${GROUP_ID}/invites`,
+  removeMember: `/manage/groups/${GROUP_ID}/user`,
+  removeInvite: `/manage/groups/${GROUP_ID}/invites`,
+  exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
+}
+
+describe('group members, with managed users', function () {
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map()
+      win.metaAttributesCache.set('ol-users', [
+        JOHN_DOE,
+        BOBBY_LAPOINTE,
+        CLAIRE_JENNINGS,
+      ])
+      win.metaAttributesCache.set('ol-groupId', GROUP_ID)
+      win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
+      win.metaAttributesCache.set('ol-groupSize', 10)
+      // Managed Users is active on this group
+      win.metaAttributesCache.set('ol-managedUsersActive', true)
+    })
+
+    cy.mount(<GroupMembers />)
+  })
+
+  it('renders the group members page', function () {
+    cy.get('h1').contains('My Awesome Team')
+    cy.get('small').contains('You have added 3 of 10 available members')
+
+    cy.get('ul.managed-users-list').within(() => {
+      cy.get('li:nth-child(2)').within(() => {
+        cy.contains('john.doe@test.com')
+        cy.contains('John Doe')
+        cy.contains('15th Jan 2023')
+        cy.get(`[aria-label="Pending invite"]`)
+
+        cy.get('.badge-new-comment').contains('Pending invite')
+        cy.get(`.security-state-invite-pending`).should('exist')
+      })
+
+      cy.get('li:nth-child(3)').within(() => {
+        cy.contains('bobby.lapointe@test.com')
+        cy.contains('Bobby Lapointe')
+        cy.contains('2nd Jan 2023')
+        cy.get('.badge-new-comment').should('not.exist')
+        cy.get('i[aria-label="Not managed"]').should('exist')
+      })
+
+      cy.get('li:nth-child(4)').within(() => {
+        cy.contains('claire.jennings@test.com')
+        cy.contains('Claire Jennings')
+        cy.contains('3rd Jan 2023')
+        cy.get('.badge-new-comment').should('not.exist')
+        cy.get('i[aria-label="Managed"]').should('exist')
+      })
+    })
+  })
+
+  it('sends an invite', function () {
+    cy.intercept('POST', PATHS.addMember, {
+      statusCode: 201,
+      body: {
+        user: {
+          email: 'someone.else@test.com',
+          invite: true,
+        },
+      },
+    })
+
+    cy.get('.form-control').type('someone.else@test.com')
+    cy.get('.add-more-members-form button').click()
+
+    cy.get('ul.managed-users-list').within(() => {
+      cy.get('li:nth-child(5)').within(() => {
+        cy.contains('someone.else@test.com')
+        cy.contains('N/A')
+        cy.get(`[aria-label="Pending invite"]`)
+        cy.get('.badge-new-comment').contains('Pending invite')
+        cy.get(`.security-state-invite-pending`).should('exist')
+      })
+    })
+  })
+
+  it('tries to send an invite and displays the error', function () {
+    cy.intercept('POST', PATHS.addMember, {
+      statusCode: 500,
+      body: {
+        error: {
+          message: 'User already added',
+        },
+      },
+    })
+
+    cy.get('.form-control').type('someone.else@test.com')
+    cy.get('.add-more-members-form button').click()
+    cy.get('.alert').contains('Error: User already added')
+  })
+
+  it('checks the select all checkbox', function () {
+    cy.get('ul.managed-users-list').within(() => {
+      cy.get('li:nth-child(2)').within(() => {
+        cy.get('.select-item').should('not.be.checked')
+      })
+      cy.get('li:nth-child(3)').within(() => {
+        cy.get('.select-item').should('not.be.checked')
+      })
+    })
+
+    cy.get('.select-all').click()
+
+    cy.get('ul.managed-users-list').within(() => {
+      cy.get('li:nth-child(2)').within(() => {
+        cy.get('.select-item').should('be.checked')
+      })
+      cy.get('li:nth-child(3)').within(() => {
+        cy.get('.select-item').should('be.checked')
+      })
+    })
+  })
+
+  it('remove a member', function () {
+    cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+      statusCode: 200,
+    })
+
+    cy.get('ul.managed-users-list').within(() => {
+      cy.get('li:nth-child(2)').within(() => {
+        cy.get('.select-item').check()
+      })
+    })
+
+    cy.get('button').contains('Remove from group').click()
+
+    cy.get('small').contains('You have added 2 of 10 available members')
+    cy.get('ul.managed-users-list').within(() => {
+      cy.get('li:nth-child(2)').within(() => {
+        cy.contains('bobby.lapointe@test.com')
+        cy.contains('Bobby Lapointe')
+        cy.contains('2nd Jan 2023')
+      })
+    })
+  })
+
+  it('cannot remove a managed member', function () {
+    cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+      statusCode: 200,
+    })
+
+    cy.get('ul.managed-users-list').within(() => {
+      // Select 'Claire Jennings', a managed user
+      cy.get('li:nth-child(4)').within(() => {
+        cy.get('.select-item').check()
+      })
+    })
+
+    cy.get('button').contains('Remove from group').should('not.exist')
+  })
+
+  it('does not show the remove-member button if any of the selected users are managed', function () {
+    cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+      statusCode: 200,
+    })
+
+    cy.get('ul.managed-users-list').within(() => {
+      // Select 'Claire Jennings', a managed user
+      cy.get('li:nth-child(4)').within(() => {
+        cy.get('.select-item').check()
+      })
+      // Select another user
+      cy.get('li:nth-child(3)').within(() => {
+        cy.get('.select-item').check()
+      })
+    })
+
+    cy.get('button').contains('Remove from group').should('not.exist')
+  })
+
+  it('tries to remove a user and displays the error', function () {
+    cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+      statusCode: 500,
+    })
+
+    cy.get('ul.managed-users-list').within(() => {
+      cy.get('li:nth-child(2)').within(() => {
+        cy.get('.select-item').check()
+      })
+    })
+    cy.get('button').contains('Remove from group').click()
+
+    cy.get('.alert').contains('Sorry, something went wrong')
+  })
+})

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

@@ -0,0 +1,63 @@
+import ManagedUserDropdownButton from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button'
+
+describe('ManagedUserDropdownButton', function () {
+  describe('with managed user', function () {
+    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: 'some-group',
+        enrolledAt: new Date(),
+      },
+      isEntityAdmin: undefined,
+    }
+
+    beforeEach(function () {
+      cy.mount(<ManagedUserDropdownButton user={user} />)
+    })
+
+    it('should render the button', function () {
+      cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
+      cy.get(`.action-btn`).should('exist')
+    })
+
+    it('should show the menu when the button is clicked', function () {
+      cy.get('.action-btn').click()
+      cy.get('.delete-user-action').should('exist')
+      cy.get('.delete-user-action').then($el => {
+        Cypress.dom.isVisible($el)
+      })
+    })
+  })
+
+  describe('with non-managed user', function () {
+    const user = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: true,
+      last_active_at: new Date(),
+      enrollment: undefined,
+      isEntityAdmin: undefined,
+    }
+
+    beforeEach(function () {
+      cy.mount(<ManagedUserDropdownButton user={user} />)
+    })
+
+    it('should render the button', function () {
+      cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
+      cy.get(`.action-btn`).should('exist')
+    })
+
+    it('should show the (empty) menu when the button is clicked', function () {
+      cy.get('.action-btn').click()
+      cy.get('.no-actions-available').should('exist')
+    })
+  })
+})

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

@@ -0,0 +1,246 @@
+import sinon, { SinonStub } from 'sinon'
+import ManagedUserRow from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-row'
+import { User } from '../../../../../../types/group-management/user'
+
+describe('ManagedUserRow', function () {
+  describe('with an ordinary user', function () {
+    let user: User,
+      selectUser: SinonStub,
+      unselectUser: SinonStub,
+      selected: boolean
+
+    beforeEach(function () {
+      user = {
+        _id: 'some-user',
+        email: 'some.user@example.com',
+        first_name: 'Some',
+        last_name: 'User',
+        invite: false,
+        last_active_at: new Date('2070-11-21T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: undefined,
+      }
+      selectUser = sinon.stub()
+      unselectUser = sinon.stub()
+      selected = false
+
+      cy.mount(
+        <ManagedUserRow
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selected}
+        />
+      )
+    })
+
+    it('renders the row', function () {
+      cy.get('.row').should('exist')
+      // Checkbox
+      cy.get('.select-item').should('not.be.checked')
+      // Email
+      cy.get('.row').contains(user.email)
+      // Name
+      cy.get('.row').contains(user.first_name)
+      cy.get('.row').contains(user.last_name)
+      // Last active date
+      cy.get('.row').contains('21st Nov 2070')
+      // Managed status
+      cy.get('.row').contains('Managed')
+      // Dropdown button
+      cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
+    })
+  })
+
+  describe('with a pending invite', function () {
+    let user: User,
+      selectUser: SinonStub,
+      unselectUser: SinonStub,
+      selected: boolean
+
+    beforeEach(function () {
+      user = {
+        _id: 'some-user',
+        email: 'some.user@example.com',
+        first_name: 'Some',
+        last_name: 'User',
+        invite: true,
+        last_active_at: new Date('2070-11-21T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: undefined,
+      }
+      selectUser = sinon.stub()
+      unselectUser = sinon.stub()
+      selected = false
+
+      cy.mount(
+        <ManagedUserRow
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selected}
+        />
+      )
+    })
+
+    it('should render a "Pending invite" badge', function () {
+      cy.get('.badge-new-comment').contains('Pending invite')
+    })
+  })
+
+  describe('with a group admin', function () {
+    let user: User,
+      selectUser: SinonStub,
+      unselectUser: SinonStub,
+      selected: boolean
+
+    beforeEach(function () {
+      user = {
+        _id: 'some-user',
+        email: 'some.user@example.com',
+        first_name: 'Some',
+        last_name: 'User',
+        invite: false,
+        last_active_at: new Date('2070-11-21T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: true,
+      }
+      selectUser = sinon.stub()
+      unselectUser = sinon.stub()
+      selected = false
+
+      cy.mount(
+        <ManagedUserRow
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selected}
+        />
+      )
+    })
+
+    it('should render a "Group admin" symbol', function () {
+      cy.get('[aria-label="Group admin"].fa-user-circle-o').should('exist')
+    })
+  })
+
+  describe('user is selected', function () {
+    let user: User,
+      selectUser: SinonStub,
+      unselectUser: SinonStub,
+      selected: boolean
+
+    beforeEach(function () {
+      user = {
+        _id: 'some-user',
+        email: 'some.user@example.com',
+        first_name: 'Some',
+        last_name: 'User',
+        invite: false,
+        last_active_at: new Date('2070-11-21T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: undefined,
+      }
+      selectUser = sinon.stub()
+      unselectUser = sinon.stub()
+      // User is selected
+      selected = true
+
+      cy.mount(
+        <ManagedUserRow
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selected}
+        />
+      )
+    })
+
+    it('should render the selection box as selected', function () {
+      cy.get('.select-item').should('be.checked')
+    })
+  })
+
+  describe('selecting user row', function () {
+    let user: User,
+      selectUser: SinonStub,
+      unselectUser: SinonStub,
+      selected: boolean
+
+    beforeEach(function () {
+      user = {
+        _id: 'some-user',
+        email: 'some.user@example.com',
+        first_name: 'Some',
+        last_name: 'User',
+        invite: false,
+        last_active_at: new Date('2070-11-21T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: undefined,
+      }
+      selectUser = sinon.stub()
+      unselectUser = sinon.stub()
+      // User is not selected
+      selected = false
+
+      cy.mount(
+        <ManagedUserRow
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selected}
+        />
+      )
+    })
+
+    it('should select the user', function () {
+      cy.get('.select-item').should('not.be.checked')
+      cy.get('.select-item').click()
+      cy.get('.select-item').then(() => {
+        expect(selectUser.called).to.equal(true)
+        expect(unselectUser.called).to.equal(false)
+      })
+    })
+  })
+
+  describe('un-selecting user row', function () {
+    let user: User,
+      selectUser: SinonStub,
+      unselectUser: SinonStub,
+      selected: boolean
+
+    beforeEach(function () {
+      user = {
+        _id: 'some-user',
+        email: 'some.user@example.com',
+        first_name: 'Some',
+        last_name: 'User',
+        invite: false,
+        last_active_at: new Date('2070-11-21T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: undefined,
+      }
+      selectUser = sinon.stub()
+      unselectUser = sinon.stub()
+      // User is selected
+      selected = true
+
+      cy.mount(
+        <ManagedUserRow
+          user={user}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+          selected={selected}
+        />
+      )
+    })
+
+    it('should select the user', function () {
+      cy.get('.select-item').should('be.checked')
+      cy.get('.select-item').click()
+      cy.get('.select-item').then(() => {
+        expect(unselectUser.called).to.equal(true)
+      })
+    })
+  })
+})

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

@@ -0,0 +1,86 @@
+import ManagedUserStatus from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-status'
+import { User } from '../../../../../../types/group-management/user'
+
+describe('ManagedUserStatus', function () {
+  describe('with a pending invite', function () {
+    const user: User = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: true,
+      last_active_at: new Date(),
+      enrollment: undefined,
+      isEntityAdmin: undefined,
+    }
+    beforeEach(function () {
+      cy.mount(<ManagedUserStatus user={user} />)
+    })
+
+    it('should render a pending state', function () {
+      cy.get('.security-state-invite-pending').contains('Managed')
+    })
+  })
+
+  describe('with a managed user', function () {
+    const user: User = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: false,
+      last_active_at: new Date(),
+      enrollment: { managedBy: 'some-group', enrolledAt: new Date() },
+      isEntityAdmin: undefined,
+    }
+    beforeEach(function () {
+      cy.mount(<ManagedUserStatus user={user} />)
+    })
+
+    it('should render a pending state', function () {
+      cy.get('.security-state-managed').contains('Managed')
+    })
+  })
+
+  describe('with an un-managed user', function () {
+    const user: User = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: false,
+      last_active_at: new Date(),
+      enrollment: undefined,
+      isEntityAdmin: undefined,
+    }
+    beforeEach(function () {
+      cy.mount(<ManagedUserStatus user={user} />)
+    })
+
+    it('should render an un-managed state', function () {
+      cy.get('.security-state-not-managed').contains('Managed')
+    })
+  })
+
+  describe('with the group admin', function () {
+    const user: User = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: false,
+      last_active_at: new Date(),
+      enrollment: undefined,
+      isEntityAdmin: true,
+    }
+    beforeEach(function () {
+      cy.mount(<ManagedUserStatus user={user} />)
+    })
+
+    it('should render no state indicator', function () {
+      cy.get('.security-state-group-admin')
+        .contains('Managed')
+        .should('not.exist')
+    })
+  })
+})

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

@@ -0,0 +1,97 @@
+import ManagedUsersList from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-users-list'
+import { User } from '../../../../../../types/group-management/user'
+
+describe('ManagedUsersList', function () {
+  describe('with users', function () {
+    const users: User[] = [
+      {
+        _id: 'user-one',
+        email: 'sarah.brennan@example.com',
+        first_name: 'Sarah',
+        last_name: 'Brennan',
+        invite: false,
+        last_active_at: new Date('2070-10-22T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: undefined,
+      },
+      {
+        _id: 'some-user',
+        email: 'some.user@example.com',
+        first_name: 'Some',
+        last_name: 'User',
+        invite: false,
+        last_active_at: new Date('2070-11-21T03:00:00'),
+        enrollment: undefined,
+        isEntityAdmin: undefined,
+      },
+    ]
+    const selectedUsers: User[] = []
+    const handleSelectAllClick = () => {}
+    const selectUser = () => {}
+    const unselectUser = () => {}
+
+    beforeEach(function () {
+      cy.mount(
+        <ManagedUsersList
+          users={users}
+          selectedUsers={selectedUsers}
+          handleSelectAllClick={handleSelectAllClick}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+        />
+      )
+    })
+
+    it('should render the table headers', function () {
+      cy.get('#managed-users-list-headers').should('exist')
+
+      // Select-all checkbox
+      cy.get('#managed-users-list-headers .select-all').should('exist')
+
+      cy.get('#managed-users-list-headers').contains('Email')
+      cy.get('#managed-users-list-headers').contains('Name')
+      cy.get('#managed-users-list-headers').contains('Last Active')
+      cy.get('#managed-users-list-headers').contains('Security')
+    })
+
+    it('should render the list of users', function () {
+      cy.get('.managed-users-list')
+        .find('.managed-user-row')
+        .should('have.length', 2)
+      // First user
+      cy.get('.managed-users-list').contains(users[0].email)
+      cy.get('.managed-users-list').contains(users[0].first_name)
+      cy.get('.managed-users-list').contains(users[0].last_name)
+      // Second user
+      cy.get('.managed-users-list').contains(users[1].email)
+      cy.get('.managed-users-list').contains(users[1].first_name)
+      cy.get('.managed-users-list').contains(users[1].last_name)
+    })
+  })
+  describe('empty user list', function () {
+    const users: User[] = []
+    const selectedUsers: User[] = []
+    const handleSelectAllClick = () => {}
+    const selectUser = () => {}
+    const unselectUser = () => {}
+
+    beforeEach(function () {
+      cy.mount(
+        <ManagedUsersList
+          users={users}
+          selectedUsers={selectedUsers}
+          handleSelectAllClick={handleSelectAllClick}
+          selectUser={selectUser}
+          unselectUser={unselectUser}
+        />
+      )
+    })
+
+    it('should render the list, with a "no members" message', function () {
+      cy.get('.managed-users-list').contains('No members')
+      cy.get('.managed-users-list')
+        .find('.managed-user-row')
+        .should('have.length', 0)
+    })
+  })
+})

+ 21 - 0
services/web/test/unit/src/UserMembership/UserMembershipControllerTests.js

@@ -59,6 +59,12 @@ describe('UserMembershipController', function () {
       },
     ]
 
+    this.Settings = {
+      managedUsers: {
+        enabled: false,
+      },
+    }
+
     this.SessionManager = {
       getSessionUser: sinon.stub().returns(this.user),
       getLoggedInUserId: sinon.stub().returns(this.user._id),
@@ -84,6 +90,7 @@ describe('UserMembershipController', function () {
           '../Authentication/SessionManager': this.SessionManager,
           '../SplitTests/SplitTestHandler': this.SplitTestHandler,
           './UserMembershipHandler': this.UserMembershipHandler,
+          '@overleaf/settings': this.Settings,
         },
       }
     ))
@@ -113,6 +120,20 @@ describe('UserMembershipController', function () {
           expect(viewPath).to.equal('user_membership/group-members-react')
           expect(viewParams.users).to.deep.equal(this.users)
           expect(viewParams.groupSize).to.equal(this.subscription.membersLimit)
+          expect(viewParams.managedUsersActive).to.equal(false)
+        },
+      })
+    })
+
+    it('render group view with managed users', async function () {
+      this.req.entity.groupPolicy = { somePolicy: true }
+      this.Settings.managedUsers.enabled = true
+      return await this.UserMembershipController.manageGroupMembers(this.req, {
+        render: (viewPath, viewParams) => {
+          expect(viewPath).to.equal('user_membership/group-members-react')
+          expect(viewParams.users).to.deep.equal(this.users)
+          expect(viewParams.groupSize).to.equal(this.subscription.membersLimit)
+          expect(viewParams.managedUsersActive).to.equal(true)
         },
       })
     })

+ 8 - 0
services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js

@@ -39,6 +39,10 @@ describe('UserMembershipViewModel', function () {
       email: 'mock-email@baz.com',
       first_name: 'Name',
       lastLoggedIn: '2020-05-20T10:41:11.407Z',
+      enrollment: {
+        managedBy: 'mock-group-id',
+        enrolledAt: new Date(),
+      },
     }
   })
 
@@ -53,6 +57,7 @@ describe('UserMembershipViewModel', function () {
         first_name: null,
         last_name: null,
         _id: null,
+        enrollment: undefined,
       })
     })
 
@@ -66,6 +71,7 @@ describe('UserMembershipViewModel', function () {
         first_name: this.user.first_name,
         last_name: null,
         _id: this.user._id,
+        enrollment: this.user.enrollment,
       })
     })
   })
@@ -107,6 +113,8 @@ describe('UserMembershipViewModel', function () {
           expect(viewModel.first_name).to.equal(this.user.first_name)
           expect(viewModel.invite).to.equal(false)
           expect(viewModel.email).to.exist
+          expect(viewModel.enrollment).to.exist
+          expect(viewModel.enrollment).to.deep.equal(this.user.enrollment)
           return done()
         }
       )

+ 7 - 0
services/web/types/group-management/user.ts

@@ -1,3 +1,8 @@
+export type UserEnrollment = {
+  managedBy: string
+  enrolledAt: Date
+}
+
 export type User = {
   _id: string
   email: string
@@ -5,4 +10,6 @@ export type User = {
   last_name: string
   invite: boolean
   last_active_at: Date
+  enrollment: UserEnrollment | undefined
+  isEntityAdmin: boolean | undefined
 }