Răsfoiți Sursa

Merge pull request #30950 from overleaf/as-user-management-actions

[web] User management actions

GitOrigin-RevId: c7683c596673bc252649cdeb975edd779a6154b0
MoxAmber 5 luni în urmă
părinte
comite
b131020c43

+ 17 - 1
services/web/app/src/Features/UserMembership/UserMembershipController.mjs

@@ -177,10 +177,26 @@ async function manageGroupUsers(req, res) {
     entityConfig
   )
 
+  const { usersNoInvites, invites } = Object.groupBy(users, user =>
+    user.invite ? 'invites' : 'usersNoInvites'
+  )
+
+  const deduplicatedUsers = _.uniqBy(usersNoInvites, 'email')
+  for (const invite of invites) {
+    const alreadyAdded = deduplicatedUsers.find(
+      user => user.email === invite.email
+    )
+    if (alreadyAdded) {
+      alreadyAdded.invite = true
+    } else {
+      deduplicatedUsers.unshift(invite)
+    }
+  }
+
   res.render('user_membership/group-users-react', {
     name: entityName,
     groupId: entityPrimaryKey,
-    users: _.uniqBy(users, 'email'),
+    users: deduplicatedUsers,
     groupSize: subscription.membersLimit,
     managedUsersActive: subscription.managedUsersEnabled,
     entityAccess: UserMembershipAuthorization.hasEntityAccess()(req),

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

@@ -141,6 +141,7 @@
   "all_projects": "",
   "all_projects_will_be_transferred_immediately": "",
   "all_these_experiments_are_available_exclusively": "",
+  "allocate_license": "",
   "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "",
   "an_email_has_already_been_sent_to": "",
   "an_error_occured_while_restoring_project": "",
@@ -1564,6 +1565,7 @@
   "reviewing": "",
   "revoke": "",
   "revoke_invite": "",
+  "revoke_license": "",
   "right": "",
   "role": "",
   "rolling_texlive_build": "",

+ 195 - 25
services/web/frontend/js/features/group-management/components/group-users.tsx

@@ -1,6 +1,6 @@
 import classNames from 'classnames'
 import moment from 'moment'
-import { useMemo, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 
 import { User } from '@ol-types/group-management/user'
@@ -20,6 +20,18 @@ import getMeta from '@/utils/meta'
 
 import BackButton from './back-button'
 import { useGroupMembersContext } from '../context/group-members-context'
+import OffboardManagedUserModal from './members-table/offboard-managed-user-modal'
+import RemoveManagedUserModal from './members-table/remove-managed-user-modal'
+import UnlinkUserModal from './members-table/unlink-user-modal'
+import { GroupUserAlert } from '../utils/types'
+import DropdownButton from './members-table/dropdown-button'
+import {
+  OLModal,
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/shared/components/ol/ol-modal'
 
 const getUserRole = (user: User) =>
   user.isEntityAdmin ? 'admin' : user.isEntityManager ? 'manager' : 'member'
@@ -32,20 +44,27 @@ export default function GroupUsers() {
   const canUseAddSeatsFeature = getMeta('ol-canUseAddSeatsFeature')
   const groupSSOActive = getMeta('ol-groupSSOActive')
   const managedUsersActive = getMeta('ol-managedUsersActive')
+  const hasWriteAccess = getMeta('ol-hasWriteAccess')
+  const ownUserId = getMeta('ol-user_id')
+  const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
+    undefined
+  )
+  const [userToRemove, setUserToRemove] = useState<User | undefined>(undefined)
+  const [userToUnlink, setUserToUnlink] = useState<User | undefined>(undefined)
+  const [_groupUserAlert, setGroupUserAlert] =
+    useState<GroupUserAlert>(undefined)
 
   const {
     users,
-    // selectedUsers,
-    // addMembers,
-    // removeMembers,
-    // removeMemberLoading,
-    // removeMemberError,
-    // inviteMemberLoading,
-    // inviteError,
-    // memberAdded,
-    // paths,
+    selectedUsers,
+    setSelectedUsers,
+    selectUser,
+    unselectUser,
+    addManager,
+    removeManager,
+    addMembers,
+    removeMember,
   } = useGroupMembersContext()
-
   const [page, setPage] = useState(1)
   const numPages = Math.ceil(users.length / 10)
 
@@ -55,7 +74,62 @@ export default function GroupUsers() {
     return users.slice(firstUser, lastUser)
   }, [users, page])
 
-  const addedUsersSize = users.filter(user => user.isEntityMember).length
+  const visibleNonMangedUsers = useMemo(
+    () => paginatedUsers.filter(user => !user.enrollment?.managedBy),
+    [paginatedUsers]
+  )
+
+  const handleSelectAll = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      if (e.target.checked) {
+        setSelectedUsers(visibleNonMangedUsers)
+      } else {
+        setSelectedUsers([])
+      }
+    },
+    [visibleNonMangedUsers, setSelectedUsers]
+  )
+
+  const handleSelectUser = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>, user: User) => {
+      if (e.target.checked) {
+        selectUser(user)
+      } else {
+        unselectUser(user)
+      }
+    },
+    [selectUser, unselectUser]
+  )
+
+  const handleChangeRole = useCallback(
+    (e: React.ChangeEvent<HTMLSelectElement>, user: User) => {
+      if (e.target.value === 'manager') {
+        addManager(user.email)
+      } else if (e.target.value === 'member') {
+        if (user.isEntityManager) {
+          removeManager(user, true)
+        }
+        if (!user.isEntityMember && !user.invite) {
+          addMembers(user.email)
+        }
+      }
+    },
+    [addManager, removeManager, addMembers]
+  )
+
+  const handleRemoveUser = (user: User) => {
+    if (user.isEntityManager) {
+      removeManager(user)
+    }
+    if (user.isEntityMember || user.invite) {
+      removeMember(user)
+    }
+    setUserToRemove(undefined)
+  }
+
+  const allocatedLicenses = users.filter(
+    user => user.isEntityMember || user.invite
+  ).length
 
   return (
     <div className="container group-users-container">
@@ -79,7 +153,9 @@ export default function GroupUsers() {
             </a>
           </OLCol>
           <OLCol xs="auto" className="align-content-center">
-            <OLButton>{t('invite_users')}</OLButton>
+            <OLButton disabled={allocatedLicenses <= groupSize}>
+              {t('invite_users')}
+            </OLButton>
           </OLCol>
         </OLRow>
 
@@ -87,12 +163,12 @@ export default function GroupUsers() {
           <OLNotification
             type="info"
             content={
-              users.length === 1
+              allocatedLicenses === 1
                 ? t('you_have_1_license_and_your_plan_supports_up_to_y', {
                     groupSize,
                   })
                 : t('you_have_x_licenses_and_your_plan_supports_up_to_y', {
-                    addedUsersSize,
+                    addedUsersSize: allocatedLicenses,
                     groupSize,
                   })
             }
@@ -101,7 +177,7 @@ export default function GroupUsers() {
                 <a
                   href="/user/subscription/group/add-users"
                   className={classNames({
-                    'btn btn-premium': addedUsersSize === groupSize,
+                    'btn btn-premium': allocatedLicenses === groupSize,
                   })}
                 >
                   {t('buy_more_licenses')}
@@ -117,7 +193,16 @@ export default function GroupUsers() {
               <thead>
                 <tr>
                   <th>
-                    <OLFormCheckbox />
+                    {hasWriteAccess && visibleNonMangedUsers.length > 0 && (
+                      <OLFormCheckbox
+                        autoComplete="off"
+                        onChange={handleSelectAll}
+                        checked={
+                          selectedUsers.length === visibleNonMangedUsers.length
+                        }
+                        aria-label={t('select_all')}
+                      />
+                    )}
                   </th>
                   <th>{t('email')}</th>
                   <th>{t('name')}</th>
@@ -130,24 +215,34 @@ export default function GroupUsers() {
                   {managedUsersActive && (
                     <th className="text-center">{t('managed')}</th>
                   )}
-                  <th />
+                  {hasWriteAccess && <th />}
                 </tr>
               </thead>
               <tbody>
                 {paginatedUsers.map(user => (
                   <tr key={user.email} className="align-middle">
                     <td>
-                      <OLFormCheckbox />
+                      {hasWriteAccess &&
+                        !user.enrollment?.managedBy &&
+                        !user.isEntityAdmin &&
+                        user._id !== ownUserId && (
+                          <OLFormCheckbox
+                            autoComplete="off"
+                            onChange={e => handleSelectUser(e, user)}
+                            checked={selectedUsers.includes(user)}
+                            aria-label={t('select_user')}
+                          />
+                        )}
                     </td>
                     <td>{user.email}</td>
                     <td className="text-nowrap">
                       {user.first_name} {user.last_name}
                     </td>
                     <td className="text-nowrap">
-                      {user.invite ? (
-                        <OLTag>{t('pending_invite')}</OLTag>
-                      ) : (
+                      {user.last_active_at ? (
                         moment(user.last_active_at).format('Do MMM YYYY')
+                      ) : (
+                        <OLTag>{t('pending_invite')}</OLTag>
                       )}
                     </td>
                     <td>
@@ -156,9 +251,17 @@ export default function GroupUsers() {
                         name="user-role"
                         defaultValue={getUserRole(user)}
                         className="user-role-select"
-                        disabled
+                        onChange={e => handleChangeRole(e, user)}
+                        disabled={
+                          !hasWriteAccess ||
+                          user.isEntityAdmin ||
+                          user._id === null ||
+                          user._id === ownUserId
+                        }
                       >
-                        <option value="admin">{t('admin_titlecase')}</option>
+                        <option value="admin" hidden>
+                          {t('admin_titlecase')}
+                        </option>
                         <option value="manager">{t('manager')}</option>
                         <option value="member">{t('member')}</option>
                       </OLFormSelect>
@@ -170,6 +273,12 @@ export default function GroupUsers() {
                           className="text-success"
                           accessibilityLabel={t('license_allocated')}
                         />
+                      ) : user.invite ? (
+                        <MaterialIcon
+                          type="schedule"
+                          className="text-warning"
+                          accessibilityLabel={t('pending_invite')}
+                        />
                       ) : (
                         <MaterialIcon
                           type="close"
@@ -214,7 +323,21 @@ export default function GroupUsers() {
                         )}
                       </td>
                     )}
-                    <td>...</td>
+                    <td className="cell-dropdown">
+                      {hasWriteAccess &&
+                        !user.isEntityAdmin &&
+                        user._id !== ownUserId && (
+                          <DropdownButton
+                            user={user}
+                            openOffboardingModalForUser={setUserToOffboard}
+                            openRemoveModalForUser={setUserToRemove}
+                            openUnlinkUserModal={setUserToUnlink}
+                            setGroupUserAlert={setGroupUserAlert}
+                            groupId={groupId}
+                            combinedUserManagement
+                          />
+                        )}
+                    </td>
                   </tr>
                 ))}
               </tbody>
@@ -277,6 +400,53 @@ export default function GroupUsers() {
           </OLCol>
         </OLRow>
       </OLCard>
+      {userToOffboard && (
+        <OffboardManagedUserModal
+          user={userToOffboard}
+          groupId={groupId}
+          allMembers={users.filter(user => user.isEntityMember)}
+          onClose={() => setUserToOffboard(undefined)}
+        />
+      )}
+      {userToRemove &&
+        (userToRemove.enrollment?.managedBy === groupId ? (
+          <RemoveManagedUserModal
+            user={userToRemove}
+            groupId={groupId}
+            onClose={() => setUserToRemove(undefined)}
+          />
+        ) : (
+          <OLModal
+            show={userToRemove !== undefined}
+            onHide={() => setUserToRemove(undefined)}
+          >
+            <OLModalHeader>
+              <OLModalTitle>Remove user from group?</OLModalTitle>
+            </OLModalHeader>
+            <OLModalBody>
+              Are you sure you want to remove {userToRemove.email} from the
+              group?
+            </OLModalBody>
+            <OLModalFooter>
+              <OLButton
+                onClick={() => setUserToRemove(undefined)}
+                variant="secondary"
+              >
+                Cancel
+              </OLButton>
+              <OLButton onClick={() => handleRemoveUser(userToRemove)}>
+                Remove
+              </OLButton>
+            </OLModalFooter>
+          </OLModal>
+        ))}
+      {userToUnlink && (
+        <UnlinkUserModal
+          user={userToUnlink}
+          onClose={() => setUserToUnlink(undefined)}
+          setGroupUserAlert={setGroupUserAlert}
+        />
+      )}
     </div>
   )
 }

+ 54 - 12
services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx

@@ -33,6 +33,7 @@ type ManagedUserDropdownButtonProps = {
   openUnlinkUserModal: (user: User) => void
   groupId: string
   setGroupUserAlert: Dispatch<SetStateAction<GroupUserAlert>>
+  combinedUserManagement?: boolean
 }
 
 export default function DropdownButton({
@@ -42,9 +43,10 @@ export default function DropdownButton({
   openUnlinkUserModal,
   groupId,
   setGroupUserAlert,
+  combinedUserManagement = false,
 }: ManagedUserDropdownButtonProps) {
   const { t } = useTranslation()
-  const { removeMember } = useGroupMembersContext()
+  const { removeMember, addMembers } = useGroupMembersContext()
   const {
     runAsync: runResendManagedUserInviteAsync,
     isLoading: isResendingManagedUserInvite,
@@ -183,13 +185,25 @@ export default function DropdownButton({
   }
 
   const onRemoveFromGroup = () => {
-    removeMember(user)
+    if (combinedUserManagement) {
+      openRemoveModalForUser(user)
+    } else {
+      removeMember(user)
+    }
   }
 
   const onUnlinkUserClick = () => {
     openUnlinkUserModal(user)
   }
 
+  const onAllocateLicenseClick = () => {
+    addMembers(user.email)
+  }
+
+  const onRevokeLicenseClick = () => {
+    removeMember(user, combinedUserManagement)
+  }
+
   const buttons = []
 
   if (userPending) {
@@ -204,7 +218,12 @@ export default function DropdownButton({
       </MenuItemButton>
     )
   }
-  if (managedUsersActive && !isUserManaged && !userPending) {
+  if (
+    managedUsersActive &&
+    !isUserManaged &&
+    !userPending &&
+    user.isEntityMember
+  ) {
     buttons.push(
       <MenuItemButton
         onClick={onResendManagedUserInviteClick}
@@ -216,6 +235,27 @@ export default function DropdownButton({
       </MenuItemButton>
     )
   }
+  if (combinedUserManagement && user.isEntityManager) {
+    if (!user.isEntityMember && !user.invite) {
+      buttons.push(
+        <MenuItemButton
+          onClick={onAllocateLicenseClick}
+          key="allocate-license-action"
+        >
+          {t('allocate_license')}
+        </MenuItemButton>
+      )
+    } else {
+      buttons.push(
+        <MenuItemButton
+          onClick={onRevokeLicenseClick}
+          key="revoke-license-action"
+        >
+          {t('revoke_license')}
+        </MenuItemButton>
+      )
+    }
+  }
   if (groupSSOActive && isGroupSSOLinked) {
     buttons.push(
       <MenuItemButton
@@ -246,20 +286,20 @@ export default function DropdownButton({
   ) {
     buttons.push(
       <MenuItemButton
-        key="delete-user-action"
-        data-testid="delete-user-action"
-        onClick={onDeleteUserClick}
+        key="release-user-action"
+        data-testid="release-user-action"
+        onClick={onReleaseUserClick}
       >
-        {t('delete_permanently')}
+        {t('remove_from_group')}
       </MenuItemButton>
     )
     buttons.push(
       <MenuItemButton
-        key="release-user-action"
-        data-testid="release-user-action"
-        onClick={onReleaseUserClick}
+        key="delete-user-action"
+        data-testid="delete-user-action"
+        onClick={onDeleteUserClick}
       >
-        {t('remove_from_group')}
+        {t('delete_permanently')}
       </MenuItemButton>
     )
   } else if (!isUserManaged) {
@@ -298,7 +338,9 @@ export default function DropdownButton({
       >
         <MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
       </DropdownToggle>
-      <DropdownMenu flip={false}>{buttons}</DropdownMenu>
+      <DropdownMenu flip renderOnMount popperConfig={{ strategy: 'fixed' }}>
+        {buttons}
+      </DropdownMenu>
     </Dropdown>
   )
 }

+ 119 - 19
services/web/frontend/js/features/group-management/context/group-members-context.tsx

@@ -1,5 +1,7 @@
 import {
+  Dispatch,
   ReactNode,
+  SetStateAction,
   createContext,
   useCallback,
   useContext,
@@ -18,19 +20,22 @@ import { debugConsole } from '@/utils/debugging'
 export type GroupMembersContextValue = {
   users: User[]
   selectedUsers: User[]
+  setSelectedUsers: Dispatch<SetStateAction<User[]>>
   selectUser: (user: User) => void
   selectAllUsers: () => void
   unselectAllUsers: () => void
   selectAllNonManagedUsers: () => void
   unselectUser: (user: User) => void
   addMembers: (emailString: string) => void
-  removeMembers: (e: any) => void
-  removeMember: (user: User) => Promise<void>
+  removeMembers: (e: any, keepUsers?: boolean) => void
+  removeMember: (user: User, keepUser?: boolean) => Promise<void>
   removeMemberLoading: boolean
   removeMemberError?: APIError
   updateMemberView: (userId: string, updatedUser: User) => void
   inviteMemberLoading: boolean
   inviteError?: APIError
+  addManager: (email: string) => Promise<void>
+  removeManager: (user: User, keepUser?: boolean) => Promise<void>
   memberAdded: boolean
   paths: { [key: string]: string }
 }
@@ -48,6 +53,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
     users,
     setUsers,
     selectedUsers,
+    setSelectedUsers,
     selectAllUsers,
     unselectAllUsers,
     selectAllNonManagedUsers,
@@ -59,6 +65,8 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
   const [inviteError, setInviteError] = useState<APIError>()
   const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
   const [removeMemberError, setRemoveMemberError] = useState<APIError>()
+  const [addManagerError, setAddManagerError] = useState<APIError>()
+  const [removeManagerError, setRemoveManagerError] = useState<APIError>()
   const [memberAdded, setMemberAdded] = useState(false)
 
   const groupId = getMeta('ol-groupId')
@@ -69,10 +77,27 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
       removeMember: `/manage/groups/${groupId}/user`,
       removeInvite: `/manage/groups/${groupId}/invites`,
       exportMembers: `/manage/groups/${groupId}/members/export`,
+      addManager: `/manage/groups/${groupId}/managers`,
+      removeManager: `/manage/groups/${groupId}/managers`,
     }),
     [groupId]
   )
 
+  const updateMemberView = useCallback(
+    (userId: string, updatedUser: User) => {
+      setUsers(users =>
+        users.map(u => {
+          if (u._id === userId) {
+            return updatedUser
+          } else {
+            return u
+          }
+        })
+      )
+    },
+    [setUsers]
+  )
+
   const addMembers = useCallback(
     (emailString: string) => {
       setInviteError(undefined)
@@ -93,6 +118,11 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
             )
             if (!alreadyListed) {
               setUsers(users => [...users, data.user])
+            } else {
+              updateMemberView(alreadyListed._id, {
+                ...alreadyListed,
+                invite: true,
+              })
             }
           }
         } catch (error: unknown) {
@@ -109,13 +139,13 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
         })
       })
     },
-    [paths.addMember, users, setUsers]
+    [paths.addMember, users, setUsers, updateMemberView]
   )
 
   const removeMember = useCallback(
-    async (user: User) => {
+    async (user: User, keepUser = false) => {
       let url
-      if (paths.removeInvite && user.invite && user._id == null) {
+      if (paths.removeInvite && user.invite && user._id === null) {
         url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
       } else if (paths.removeMember && user._id) {
         url = `${paths.removeMember}/${user._id}`
@@ -125,7 +155,16 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
       setRemoveMemberInflightCount(count => count + 1)
       try {
         await deleteJSON(url, {})
-        setUsers(users => users.filter(u => u !== user))
+        if (!keepUser) {
+          setUsers(users => users.filter(u => u !== user))
+        } else {
+          updateMemberView(user._id, {
+            ...user,
+            invite: false,
+            isEntityMember: false,
+            enrollment: {},
+          })
+        }
         unselectUser(user)
       } catch (error: unknown) {
         debugConsole.error(error)
@@ -133,11 +172,17 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
       }
       setRemoveMemberInflightCount(count => count - 1)
     },
-    [unselectUser, setUsers, paths.removeInvite, paths.removeMember]
+    [
+      unselectUser,
+      setUsers,
+      paths.removeInvite,
+      paths.removeMember,
+      updateMemberView,
+    ]
   )
 
   const removeMembers = useCallback(
-    (e: any) => {
+    (e: any, keepUsers = false) => {
       e.preventDefault()
       setRemoveMemberError(undefined)
       ;(async () => {
@@ -145,32 +190,78 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
           if (user?.enrollment?.managedBy) {
             continue
           }
-          await removeMember(user)
+          await removeMember(user, keepUsers)
         }
       })()
     },
     [selectedUsers, removeMember]
   )
 
-  const updateMemberView = useCallback(
-    (userId: string, updatedUser: User) => {
-      setUsers(
-        users.map(u => {
-          if (u._id === userId) {
-            return updatedUser
+  const addManager = useCallback(
+    async (email: string) => {
+      setAddManagerError(undefined)
+
+      try {
+        const data = await postJSON<{ user: User }>(paths.addManager, {
+          body: {
+            email,
+          },
+        })
+        if (data.user) {
+          const alreadyListed = users.find(
+            user => user.email === data.user.email
+          )
+          if (!alreadyListed) {
+            setUsers(users => [...users, data.user])
           } else {
-            return u
+            updateMemberView(alreadyListed._id, {
+              ...alreadyListed,
+              isEntityManager: true,
+            })
           }
-        })
-      )
+        }
+      } catch (error: unknown) {
+        debugConsole.error(error)
+        setAddManagerError((error as FetchError)?.data?.error || {})
+      }
+    },
+    [paths.addManager, users, setUsers, updateMemberView]
+  )
+
+  const removeManager = useCallback(
+    async (user: User, keepUser = false) => {
+      setRemoveMemberError(undefined)
+
+      let url
+      if (paths.removeManager && user._id) {
+        url = `${paths.removeManager}/${user._id}`
+      } else {
+        return
+      }
+      try {
+        await deleteJSON(url, {})
+        if (!keepUser) {
+          setUsers(users => users.filter(u => u !== user))
+        } else {
+          updateMemberView(user._id, {
+            ...user,
+            isEntityManager: false,
+          })
+        }
+        unselectUser(user)
+      } catch (error: unknown) {
+        debugConsole.error(error)
+        setRemoveManagerError((error as FetchError)?.data?.error || {})
+      }
     },
-    [setUsers, users]
+    [unselectUser, paths.removeManager, setUsers, updateMemberView]
   )
 
   const value = useMemo<GroupMembersContextValue>(
     () => ({
       users,
       selectedUsers,
+      setSelectedUsers,
       selectAllUsers,
       unselectAllUsers,
       selectAllNonManagedUsers,
@@ -184,11 +275,16 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
       removeMemberError,
       inviteMemberLoading: inviteUserInflightCount > 0,
       inviteError,
+      addManager,
+      addManagerError,
+      removeManager,
+      removeManagerError,
       memberAdded,
       paths,
     }),
     [
       users,
+      setSelectedUsers,
       selectedUsers,
       selectAllUsers,
       unselectAllUsers,
@@ -203,6 +299,10 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
       removeMemberError,
       inviteUserInflightCount,
       inviteError,
+      addManager,
+      addManagerError,
+      removeManager,
+      removeManagerError,
       memberAdded,
       paths,
     ]

+ 1 - 0
services/web/frontend/js/features/group-management/hooks/use-user-selection.ts

@@ -28,6 +28,7 @@ export default function useUserSelection(initialUsers: User[]) {
     users,
     setUsers,
     selectedUsers,
+    setSelectedUsers,
     selectUser,
     unselectUser,
     selectAllUsers,

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

@@ -169,6 +169,7 @@
   "all_templates": "All templates",
   "all_the_pros_of_our_standard_plan_plus_unlimited_collab": "All the pros of our standard plan, plus unlimited collaborators per project.",
   "all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.",
+  "allocate_license": "Allocate license",
   "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).",
   "already_have_an_account": "Already have an account?",
   "already_have_sl_account": "Already have an __appName__ account?",
@@ -2014,6 +2015,7 @@
   "reviewing": "Reviewing",
   "revoke": "Revoke",
   "revoke_invite": "Revoke Invite",
+  "revoke_license": "Revoke license",
   "right": "Right",
   "ro": "Romanian",
   "role": "Role",

+ 2 - 2
services/web/test/frontend/features/group-management/components/group-users.spec.tsx

@@ -74,7 +74,7 @@ describe('GroupUsers', function () {
 
     it('displays license information correctly', function () {
       cy.get('.license-info').contains(
-        'You have allocated 2 licenses and your plan supports up to 10'
+        'You have allocated 3 licenses and your plan supports up to 10'
       )
     })
 
@@ -110,7 +110,7 @@ describe('GroupUsers', function () {
             'have.value',
             'member'
           )
-          cy.contains('License not allocated')
+          cy.contains('Pending invite')
         })
 
         cy.get('tr:nth-child(4)').within(() => {

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

@@ -210,6 +210,7 @@ describe('DropdownButton', function () {
         last_active_at: new Date(),
         enrollment: {},
         isEntityAdmin: undefined,
+        isEntityMember: true,
       }
 
       beforeEach(function () {
@@ -451,6 +452,7 @@ describe('DropdownButton', function () {
           ],
         },
         isEntityAdmin: undefined,
+        isEntityMember: true,
       }
 
       beforeEach(function () {
@@ -493,6 +495,7 @@ describe('DropdownButton', function () {
           sso: [],
         },
         isEntityAdmin: undefined,
+        isEntityMember: true,
       }
 
       beforeEach(function () {
@@ -542,6 +545,7 @@ describe('DropdownButton', function () {
           ],
         },
         isEntityAdmin: true,
+        isEntityMember: true,
       }
 
       beforeEach(function () {
@@ -587,6 +591,7 @@ describe('DropdownButton', function () {
           sso: [],
         },
         isEntityAdmin: true,
+        isEntityMember: true,
       }
 
       beforeEach(function () {