[web] Add admin permissions modify-group-member and modify-managed-group-member (#27665)

* Add capability `modify-managed-group-member` & `modify-group-member`

* Check `modify-managed-group-member` & `modify-group-member` (backend)

* Check `modify-managed-group-member` & `modify-group-member` (frontend)

* Update tests

* Update with `ol-hasWriteAccess` flag

* Update tests

* Move functions to AdminAuthorizationHelper.js

* Update import to fix build error

* Add `ol-hasWriteAccess` to types

* Use `hasAdminAccess()` instead of `req?.user?.isAdmin`

* Add tests on `/manage/groups/:id/invites` depending on admin roles

* Reuse `UserMembershipAuthorization.hasAdminCapability`

* Fix: Add entityAccess check

* Update unit test

* Rename `hasAdminGroupMemberCapability` to `hasModifyGroupMemberCapability`

* Remove useless and redundant `hasWriteAccess` check

* Restore stub in afterEach

GitOrigin-RevId: 4b6d83751121b43d4c19d0dbd82a4833cf7a6f24
This commit is contained in:
Antoine Clausse
2025-08-14 13:37:06 +02:00
committed by Copybot
parent fcd6c44dc3
commit ba97b96815
14 changed files with 82 additions and 22 deletions

View File

@@ -16,6 +16,14 @@ const UserMembershipAuthorization = {
hasAdminCapability,
hasModifyGroupMemberCapability(req, res) {
return hasAdminCapability(
req.entity.managedUsersEnabled
? 'modify-managed-group-member'
: 'modify-group-member'
)(req, res)
},
hasEntityAccess() {
return req => {
if (!req.entity) {

View File

@@ -14,6 +14,7 @@ import { expressify } from '@overleaf/promise-utils'
import PlansLocator from '../Subscription/PlansLocator.js'
import RecurlyClient from '../Subscription/RecurlyClient.js'
import Modules from '../../infrastructure/Modules.js'
import UserMembershipAuthorization from './UserMembershipAuthorization.js'
async function manageGroupMembers(req, res, next) {
const { entity: subscription, entityConfig } = req
@@ -59,6 +60,7 @@ async function manageGroupMembers(req, res, next) {
groupSSOActive: ssoConfig?.enabled,
canUseFlexibleLicensing: plan?.canUseFlexibleLicensing,
canUseAddSeatsFeature,
entityAccess: UserMembershipAuthorization.hasEntityAccess()(req),
})
}

View File

@@ -51,6 +51,19 @@ const UserMembershipMiddleware = {
]),
],
requireGroupMemberManagementAccess: [
AuthenticationController.requireLogin(),
fetchEntityConfig('group'),
fetchEntity(),
requireEntity(),
useAdminCapabilities,
allowAccessIfAny([
UserMembershipAuthorization.hasEntityAccess(),
UserMembershipAuthorization.hasStaffAccess('groupManagement'),
UserMembershipAuthorization.hasModifyGroupMemberCapability,
]),
],
requireGroupMetricsAccess: [
AuthenticationController.requireLogin(),
fetchEntityConfig('group'),

View File

@@ -26,13 +26,13 @@ export default {
)
webRouter.post(
'/manage/groups/:id/invites',
UserMembershipMiddleware.requireGroupManagementAccess,
UserMembershipMiddleware.requireGroupMemberManagementAccess,
RateLimiterMiddleware.rateLimit(rateLimiters.createTeamInvite),
TeamInvitesController.createInvite
)
webRouter.post(
'/manage/groups/:id/resendInvite',
UserMembershipMiddleware.requireGroupManagementAccess,
UserMembershipMiddleware.requireGroupMemberManagementAccess,
RateLimiterMiddleware.rateLimit(rateLimiters.createTeamInvite),
TeamInvitesController.resendInvite
)
@@ -43,7 +43,7 @@ export default {
)
webRouter.delete(
'/manage/groups/:id/invites/:email',
UserMembershipMiddleware.requireGroupManagementAccess,
UserMembershipMiddleware.requireGroupMemberManagementAccess,
TeamInvitesController.revokeInvite
)
webRouter.get(

View File

@@ -4,11 +4,13 @@ block entrypointVar
- entrypoint = 'pages/user/subscription/group-management/group-members'
block append meta
- var hasWriteAccess = entityAccess || (hasAdminAccess() && hasAdminCapability(managedUsersActive ? 'modify-managed-group-member' : 'modify-group-member')) || (getSessionUser().staffAccess && getSessionUser().staffAccess.groupManagement)
meta(name='ol-user' data-type='json' content=user)
meta(name='ol-users' data-type='json' content=users)
meta(name='ol-groupId' data-type='string' content=groupId)
meta(name='ol-groupName' data-type='string' content=name)
meta(name='ol-groupSize' data-type='number' content=groupSize)
meta(name='ol-hasWriteAccess' data-type='boolean' content=hasWriteAccess)
meta(
name='ol-managedUsersActive'
data-type='boolean'

View File

@@ -37,6 +37,7 @@ export default function GroupMembers() {
const groupSize = getMeta('ol-groupSize')
const canUseFlexibleLicensing = getMeta('ol-canUseFlexibleLicensing')
const canUseAddSeatsFeature = getMeta('ol-canUseAddSeatsFeature')
const hasWriteAccess = getMeta('ol-hasWriteAccess')
const handleEmailsChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@@ -133,10 +134,10 @@ export default function GroupMembers() {
</div>
<div className="row-spaced-small">
<ErrorAlert error={removeMemberError} />
<MembersList groupId={groupId} />
<MembersList groupId={groupId} hasWriteAccess={hasWriteAccess} />
</div>
<hr />
{users.length < groupSize && (
{hasWriteAccess && users.length < groupSize && (
<div
className="add-more-members-form"
data-testid="add-more-members-form"
@@ -186,7 +187,8 @@ export default function GroupMembers() {
</form>
</div>
)}
{users.length >= groupSize && users.length > 0 && (
{(!hasWriteAccess ||
(users.length >= groupSize && users.length > 0)) && (
<>
<ErrorAlert error={inviteError} />
<OLRow>

View File

@@ -20,6 +20,7 @@ type ManagedUserRowProps = {
openUnlinkUserModal: (user: User) => void
groupId: string
setGroupUserAlert: Dispatch<SetStateAction<GroupUserAlert>>
hasWriteAccess: boolean
}
export default function MemberRow({
@@ -29,6 +30,7 @@ export default function MemberRow({
openUnlinkUserModal,
setGroupUserAlert,
groupId,
hasWriteAccess,
}: ManagedUserRowProps) {
const { t } = useTranslation()
const managedUsersActive = getMeta('ol-managedUsersActive')
@@ -36,7 +38,7 @@ export default function MemberRow({
return (
<tr className="managed-entity-row">
<SelectUserCheckbox user={user} />
{hasWriteAccess && <SelectUserCheckbox user={user} />}
<td
className={classnames('cell-email', {
'text-muted': user.invite,
@@ -110,16 +112,18 @@ export default function MemberRow({
</div>
</td>
)}
<td className="cell-dropdown">
<DropdownButton
user={user}
openOffboardingModalForUser={openOffboardingModalForUser}
openRemoveModalForUser={openRemoveModalForUser}
openUnlinkUserModal={openUnlinkUserModal}
setGroupUserAlert={setGroupUserAlert}
groupId={groupId}
/>
</td>
{hasWriteAccess && (
<td className="cell-dropdown">
<DropdownButton
user={user}
openOffboardingModalForUser={openOffboardingModalForUser}
openRemoveModalForUser={openRemoveModalForUser}
openUnlinkUserModal={openUnlinkUserModal}
setGroupUserAlert={setGroupUserAlert}
groupId={groupId}
/>
</td>
)}
</tr>
)
}

View File

@@ -26,6 +26,7 @@ const USERS_DISPLAY_LIMIT = 50
type ManagedUsersListProps = {
groupId: string
hasWriteAccess: boolean
}
function isUserSearchMatch(user: User, search: NonEmptyString): boolean {
@@ -40,7 +41,10 @@ function isUserSearchMatch(user: User, search: NonEmptyString): boolean {
)
}
export default function MembersList({ groupId }: ManagedUsersListProps) {
export default function MembersList({
groupId,
hasWriteAccess,
}: ManagedUsersListProps) {
const { t } = useTranslation()
const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
undefined
@@ -155,7 +159,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
>
<thead>
<tr ref={tHeadRowRef}>
<SelectAllCheckbox />
{hasWriteAccess && <SelectAllCheckbox />}
<th className="cell-email">{t('email')}</th>
<th className="cell-name">{t('name')}</th>
<th className="cell-last-active">
@@ -178,7 +182,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
{managedUsersActive && (
<th className="cell-managed">{t('managed')}</th>
)}
<th />
{hasWriteAccess && <th />}
</tr>
</thead>
<tbody>
@@ -203,6 +207,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
openUnlinkUserModal={setUserToUnlink}
setGroupUserAlert={setGroupUserAlert}
groupId={groupId}
hasWriteAccess={hasWriteAccess}
/>
))}
</tbody>

View File

@@ -43,6 +43,7 @@ describe('GroupMembers', function () {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
win.metaAttributesCache.set('ol-hasWriteAccess', true)
})
cy.mount(
@@ -243,6 +244,7 @@ describe('GroupMembers', function () {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-managedUsersActive', true)
win.metaAttributesCache.set('ol-hasWriteAccess', true)
})
mountGroupMembersProvider()
})
@@ -514,6 +516,7 @@ describe('GroupMembers', function () {
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-canUseFlexibleLicensing', true)
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', true)
win.metaAttributesCache.set('ol-hasWriteAccess', true)
})
})

View File

@@ -77,6 +77,7 @@ describe('group members, with managed users', function () {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-managedUsersActive', true)
win.metaAttributesCache.set('ol-hasWriteAccess', true)
})
mountGroupMembersProvider()
})

View File

@@ -34,6 +34,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -87,6 +88,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -127,6 +129,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -166,6 +169,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -215,6 +219,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -267,6 +272,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -307,6 +313,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -346,6 +353,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -395,6 +403,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -448,6 +457,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -488,6 +498,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -527,6 +538,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -577,6 +589,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -630,6 +643,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -670,6 +684,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)
@@ -709,6 +724,7 @@ describe('MemberRow', function () {
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
hasWriteAccess
/>
</GroupMembersProvider>
)

View File

@@ -7,7 +7,7 @@ const groupId = 'somegroup'
function mountManagedUsersList() {
cy.mount(
<GroupMembersProvider>
<MembersList groupId={groupId} />
<MembersList groupId={groupId} hasWriteAccess />
</GroupMembersProvider>
)
}
@@ -166,7 +166,7 @@ describe('MembersList', function () {
})
cy.mount(
<GroupMembersProvider>
<MembersList groupId={groupId} />
<MembersList groupId={groupId} hasWriteAccess />
</GroupMembersProvider>
)
})

View File

@@ -35,6 +35,7 @@ describe('UserMembershipController', function () {
ctx.subscription = {
_id: 'mock-subscription-id',
admin_id: 'mock-admin-id',
manager_ids: ['mock-admin-id'],
fetchV1Data: callback => callback(null, ctx.subscription),
}
ctx.institution = {
@@ -201,6 +202,7 @@ describe('UserMembershipController', function () {
describe('index', function () {
beforeEach(function (ctx) {
ctx.req.user = ctx.user
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.group
})

View File

@@ -5,9 +5,11 @@ export type AdminCapability =
| 'create-subscription'
| 'modify-feature-override'
| 'modify-group'
| 'modify-group-member'
| 'modify-group-setting'
| 'modify-login-status'
| 'modify-managed-group'
| 'modify-managed-group-member'
| 'modify-project'
| 'manage-survey'
| 'modify-split-test'