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

[web] User management actions

GitOrigin-RevId: c7683c596673bc252649cdeb975edd779a6154b0
This commit is contained in:
MoxAmber
2026-03-06 09:08:25 +00:00
committed by Copybot
parent ebdb0b12cf
commit b131020c43
9 changed files with 399 additions and 61 deletions
@@ -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),
@@ -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": "",
@@ -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>
)
}
@@ -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
@@ -244,15 +284,6 @@ export default function DropdownButton({
!user.isEntityAdmin &&
(!isUserGroupManager || userId !== user._id)
) {
buttons.push(
<MenuItemButton
key="delete-user-action"
data-testid="delete-user-action"
onClick={onDeleteUserClick}
>
{t('delete_permanently')}
</MenuItemButton>
)
buttons.push(
<MenuItemButton
key="release-user-action"
@@ -262,6 +293,15 @@ export default function DropdownButton({
{t('remove_from_group')}
</MenuItemButton>
)
buttons.push(
<MenuItemButton
key="delete-user-action"
data-testid="delete-user-action"
onClick={onDeleteUserClick}
>
{t('delete_permanently')}
</MenuItemButton>
)
} else if (!isUserManaged) {
buttons.push(
<MenuItemButton
@@ -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>
)
}
@@ -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
} else {
return u
}
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 {
updateMemberView(alreadyListed._id, {
...alreadyListed,
isEntityManager: true,
})
}
}
} catch (error: unknown) {
debugConsole.error(error)
setAddManagerError((error as FetchError)?.data?.error || {})
}
},
[setUsers, users]
[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 || {})
}
},
[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,
]
@@ -28,6 +28,7 @@ export default function useUserSelection(initialUsers: User[]) {
users,
setUsers,
selectedUsers,
setSelectedUsers,
selectUser,
unselectUser,
selectAllUsers,
+2
View File
@@ -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",
@@ -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(() => {
@@ -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 () {