Merge pull request #25983 from overleaf/ii-managed-users-make-unmanaged-roles-access

[web] Prevent managers from removing/deleting themselves

GitOrigin-RevId: 9287dc06bab8024bf03fecff678a4118a9456919
This commit is contained in:
ilkin-overleaf
2025-06-12 12:19:45 +03:00
committed by Copybot
parent 277e59fbd5
commit cfc6ff0759
12 changed files with 181 additions and 6 deletions

View File

@@ -162,6 +162,45 @@ const SubscriptionLocator = {
}
: null
},
async getUserSubscriptionStatus(userId) {
let usersSubscription = { personal: false, group: false }
if (!userId) {
return usersSubscription
}
const memberSubscriptions =
await SubscriptionLocator.getMemberSubscriptions(userId)
const hasActiveGroupSubscription = memberSubscriptions.some(
subscription =>
subscription.recurlyStatus?.state === 'active' && subscription.groupPlan
)
if (hasActiveGroupSubscription) {
// Member of a group plan
usersSubscription = { ...usersSubscription, group: true }
}
const personalSubscription =
await SubscriptionLocator.getUsersSubscription(userId)
if (personalSubscription) {
const hasActivePersonalSubscription =
personalSubscription.recurlyStatus?.state === 'active'
if (hasActivePersonalSubscription) {
if (personalSubscription.groupPlan) {
// Owner of a group plan
usersSubscription = { ...usersSubscription, group: true }
} else {
// Owner of an individual plan
usersSubscription = { ...usersSubscription, personal: true }
}
}
}
return usersSubscription
},
}
module.exports = {

View File

@@ -132,6 +132,9 @@ async function viewInvite(req, res, next) {
logger.error({ err }, 'error getting subscription admin email')
}
const usersSubscription =
await SubscriptionLocator.promises.getUserSubscriptionStatus(userId)
return res.render('subscriptions/team/invite-managed', {
inviterName: invite.inviterName,
inviteToken: invite.token,
@@ -141,6 +144,7 @@ async function viewInvite(req, res, next) {
groupSSOActive,
subscriptionId: subscription._id.toString(),
user: sessionUser,
usersSubscription,
})
} else {
let currentManagedUserAdminEmail

View File

@@ -31,8 +31,11 @@ async function manageGroupMembers(req, res, next) {
)
const ssoConfig = await SSOConfig.findById(subscription.ssoConfig).exec()
const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
const userId = SessionManager.getLoggedInUserId(req.session)
const userId = SessionManager.getLoggedInUserId(req.session)?.toString()
const isAdmin = subscription.admin_id.toString() === userId
const isUserGroupManager =
Boolean(subscription.manager_ids?.some(id => id.toString() === userId)) &&
!isAdmin
const recurlySubscription = subscription.recurlySubscription_id
? await RecurlyClient.promises.getSubscription(
subscription.recurlySubscription_id
@@ -51,6 +54,7 @@ async function manageGroupMembers(req, res, next) {
users,
groupSize: subscription.membersLimit,
managedUsersActive: subscription.managedUsersEnabled,
isUserGroupManager,
groupSSOActive: ssoConfig?.enabled,
canUseFlexibleLicensing: plan?.canUseFlexibleLicensing,
canUseAddSeatsFeature,

View File

@@ -13,6 +13,7 @@ block append meta
meta(name="ol-groupSSOActive" data-type="boolean" content=groupSSOActive)
meta(name="ol-subscriptionId" data-type="string" content=subscriptionId)
meta(name="ol-user" data-type="json" content=user)
meta(name="ol-usersSubscription" data-type="json" content=usersSubscription)
block content
main.content.content-alt.team-invite#invite-managed-root

View File

@@ -10,6 +10,7 @@ block append meta
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)
meta(name="ol-isUserGroupManager", data-type="boolean", content=isUserGroupManager)
meta(name="ol-groupSSOActive", data-type="boolean", content=groupSSOActive)
meta(name="ol-canUseFlexibleLicensing", data-type="boolean", content=canUseFlexibleLicensing)
meta(name="ol-canUseAddSeatsFeature", data-type="boolean", content=canUseAddSeatsFeature)

View File

@@ -201,6 +201,8 @@
"can_view_content": "",
"cancel": "",
"cancel_add_on": "",
"cancel_any_existing_subscriptions": "",
"cancel_any_existing_subscriptions_and_leave_any_group_subscriptions": "",
"cancel_anytime": "",
"cancel_my_account": "",
"cancel_my_subscription": "",
@@ -1142,7 +1144,7 @@
"only_group_admin_or_managers_can_delete_your_account_2": "",
"only_group_admin_or_managers_can_delete_your_account_3": "",
"only_group_admin_or_managers_can_delete_your_account_4": "",
"only_group_admin_or_managers_can_delete_your_account_5": "",
"only_group_admin_or_managers_can_delete_your_account_8": "",
"only_importer_can_refresh": "",
"open_action_menu": "",
"open_advanced_reference_search": "",

View File

@@ -60,7 +60,8 @@ export default function DropdownButton({
const managedUsersActive = getMeta('ol-managedUsersActive')
const groupSSOActive = getMeta('ol-groupSSOActive')
const userId = getMeta('ol-user_id')
const isUserGroupManager = getMeta('ol-isUserGroupManager')
const userPending = user.invite
const isGroupSSOLinked =
!userPending && user.enrollment?.sso?.some(sso => sso.groupId === groupId)
@@ -238,7 +239,11 @@ export default function DropdownButton({
</MenuItemButton>
)
}
if (isUserManaged && !user.isEntityAdmin) {
if (
isUserManaged &&
!user.isEntityAdmin &&
(!isUserGroupManager || userId !== user._id)
) {
buttons.push(
<MenuItemButton
key="delete-user-action"
@@ -272,7 +277,7 @@ export default function DropdownButton({
if (buttons.length === 0) {
buttons.push(
<DropdownListItem>
<DropdownListItem key="no-actions-available">
<DropdownItem
as="button"
tabIndex={-1}

View File

@@ -147,6 +147,7 @@ export interface Meta {
'ol-isRegisteredViaGoogle': boolean
'ol-isRestrictedTokenMember': boolean
'ol-isSaas': boolean
'ol-isUserGroupManager': boolean
'ol-itm_campaign': string
'ol-itm_content': string
'ol-itm_referrer': string
@@ -268,6 +269,7 @@ export interface Meta {
'ol-users': ManagedUser[]
'ol-usersBestSubscription': ProjectDashboardSubscription | undefined
'ol-usersEmail': string | undefined
'ol-usersSubscription': { personal: boolean; group: boolean }
'ol-validationStatus': ValidationStatus
'ol-wikiEnabled': boolean
'ol-writefullCssUrl': string

View File

@@ -263,6 +263,8 @@
"can_view_content": "Can view content",
"cancel": "Cancel",
"cancel_add_on": "Cancel add-on",
"cancel_any_existing_subscriptions": "Cancel any existing subscriptions. <0>This can be managed from the Subscription page.</0>",
"cancel_any_existing_subscriptions_and_leave_any_group_subscriptions": "Cancel any existing subscriptions, and leave any group subscriptions other than the one managing your account. <0>This can be managed from the Subscription page.</0>",
"cancel_anytime": "Were confident that youll love __appName__, but if not, you can cancel anytime and request your money back, hassle free, within 30 days.",
"cancel_my_account": "Cancel my subscription",
"cancel_my_subscription": "Cancel my subscription",
@@ -1498,7 +1500,7 @@
"only_group_admin_or_managers_can_delete_your_account_2": "Only your group admin or group managers will be able to delete your account.",
"only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.",
"only_group_admin_or_managers_can_delete_your_account_4": "Once you have become a managed user, you cannot change back. <0>Learn more about managed Overleaf accounts.</0>",
"only_group_admin_or_managers_can_delete_your_account_5": "For more information, see the \"Managed Accounts\" section in our terms of use, which you agree to by clicking Accept invitation",
"only_group_admin_or_managers_can_delete_your_account_8": "Well cancel the renewal of your subscription, reach out to Support to request a pro-rata refund. Your individual subscription will be terminated when your account becomes managed.",
"only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
"open_action_menu": "Open __name__ action menu",
"open_advanced_reference_search": "Open advanced reference search",

View File

@@ -175,6 +175,7 @@ describe('DropdownButton', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
win.metaAttributesCache.set('ol-isUserGroupManager', true)
})
mountDropDownComponent(user, subscriptionId)
})
@@ -637,6 +638,7 @@ describe('DropdownButton', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
win.metaAttributesCache.set('ol-isUserGroupManager', true)
})
mountDropDownComponent(user, subscriptionId)
})
@@ -687,6 +689,7 @@ describe('DropdownButton', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
win.metaAttributesCache.set('ol-isUserGroupManager', true)
})
mountDropDownComponent(user, subscriptionId)
})

View File

@@ -13,6 +13,11 @@ describe('Subscription Locator Tests', function () {
exec: sinon.stub().resolves(),
}),
find: sinon.stub().returns({
populate: sinon.stub().returns({
populate: sinon.stub().returns({
exec: sinon.stub().resolves([]),
}),
}),
exec: sinon.stub().resolves(),
}),
}
@@ -77,4 +82,110 @@ describe('Subscription Locator Tests', function () {
subscription.should.equal(this.subscription)
})
})
describe('getUserSubscriptionStatus', function () {
it('should return no active personal or group subscription when no user is passed', async function () {
const subscriptionStatus =
await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
undefined
)
expect(subscriptionStatus).to.deep.equal({
personal: false,
group: false,
})
})
it('should return no active personal or group subscription when the user has no subscription', async function () {
const subscriptionStatus =
await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
this.user._id
)
expect(subscriptionStatus).to.deep.equal({
personal: false,
group: false,
})
})
it('should return active personal subscription', async function () {
this.Subscription.findOne.returns({
exec: sinon.stub().resolves({
recurlyStatus: {
state: 'active',
},
}),
})
const subscriptionStatus =
await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
this.user._id
)
expect(subscriptionStatus).to.deep.equal({ personal: true, group: false })
})
it('should return active group subscription when member of a group plan', async function () {
this.Subscription.find.returns({
populate: sinon.stub().returns({
populate: sinon.stub().returns({
exec: sinon.stub().resolves([
{
recurlyStatus: {
state: 'active',
},
groupPlan: true,
},
]),
}),
}),
})
const subscriptionStatus =
await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
this.user._id
)
expect(subscriptionStatus).to.deep.equal({ personal: false, group: true })
})
it('should return active group subscription when owner of a group plan', async function () {
this.Subscription.findOne.returns({
exec: sinon.stub().resolves({
recurlyStatus: {
state: 'active',
},
groupPlan: true,
}),
})
const subscriptionStatus =
await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
this.user._id
)
expect(subscriptionStatus).to.deep.equal({ personal: false, group: true })
})
it('should return active personal and group subscription when has personal subscription and member of a group', async function () {
this.Subscription.find.returns({
populate: sinon.stub().returns({
populate: sinon.stub().returns({
exec: sinon.stub().resolves([
{
recurlyStatus: {
state: 'active',
},
groupPlan: true,
},
]),
}),
}),
})
this.Subscription.findOne.returns({
exec: sinon.stub().resolves({
recurlyStatus: {
state: 'active',
},
}),
})
const subscriptionStatus =
await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
this.user._id
)
expect(subscriptionStatus).to.deep.equal({ personal: true, group: true })
})
})
})

View File

@@ -184,6 +184,7 @@ describe('UserMembershipController', function () {
expect(viewParams.users).to.deep.equal(ctx.users)
expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(true)
expect(viewParams.isUserGroupManager).to.equal(false)
},
})
})