diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index 978f4d41b7..c0c107eecf 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.js @@ -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 = { diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index cbe46d2c29..29ff59b868 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -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 diff --git a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs index aaa8fa5812..4be1221255 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs +++ b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs @@ -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, diff --git a/services/web/app/views/subscriptions/team/invite-managed.pug b/services/web/app/views/subscriptions/team/invite-managed.pug index f59b8b4937..d31f12656b 100644 --- a/services/web/app/views/subscriptions/team/invite-managed.pug +++ b/services/web/app/views/subscriptions/team/invite-managed.pug @@ -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 diff --git a/services/web/app/views/user_membership/group-members-react.pug b/services/web/app/views/user_membership/group-members-react.pug index 5e8971172d..05327c4b6d 100644 --- a/services/web/app/views/user_membership/group-members-react.pug +++ b/services/web/app/views/user_membership/group-members-react.pug @@ -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) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 67071d4c2a..fa7bb80bb9 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx index 0727fefae0..9e7038363a 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx @@ -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({ ) } - if (isUserManaged && !user.isEntityAdmin) { + if ( + isUserManaged && + !user.isEntityAdmin && + (!isUserGroupManager || userId !== user._id) + ) { buttons.push( + This can be managed from the Subscription page.", + "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.", "cancel_anytime": "We’re confident that you’ll 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.", - "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": "We’ll 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", diff --git a/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx b/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx index 9f2964941f..93d24865b2 100644 --- a/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx @@ -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) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionLocatorTests.js b/services/web/test/unit/src/Subscription/SubscriptionLocatorTests.js index f66eda5b7f..e8202424fc 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionLocatorTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionLocatorTests.js @@ -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 }) + }) + }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index 47932a7fe1..18e2d8526b 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs @@ -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) }, }) })