Merge pull request #34870 from overleaf/ii-share-modal-group-sharing

[web] Make group project sharing available to Group Professional plans only

GitOrigin-RevId: ced66529818d573cfd20cef016ed4b3aafdd8d51
This commit is contained in:
ilkin-overleaf
2026-06-25 08:05:50 +00:00
committed by Copybot
parent f536b39b03
commit 13bc31cf25
12 changed files with 218 additions and 55 deletions
@@ -22,6 +22,7 @@ import PrivilegeLevels, {
} from '../Authorization/PrivilegeLevels.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import SubscriptionGroupHandler from '../Subscription/SubscriptionGroupHandler.mjs'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
// This rate limiter allows a different number of requests depending on the
// number of callaborators a user is allowed. This is implemented by providing
@@ -554,10 +555,30 @@ async function updateSharingLink(req, res) {
const privileges = body.privileges
const subscriptionId = body.subscriptionId
const currentUser = SessionManager.getSessionUser(req.session)
if (subscriptionId) {
const subscriptions =
await SubscriptionLocator.promises.getUserActiveProfessionalGroupSubscriptions(
currentUser._id,
{ _id: 1 }
)
const canShareWithSubscription = subscriptions.some(
subscription => subscription._id.toString() === subscriptionId.toString()
)
if (!canShareWithSubscription) {
logger.debug(
{ projectId, subscriptionId, userId: currentUser._id },
'cannot create a group sharing link for a non-professional or non-member subscription'
)
return res.status(403).json({ errorReason: 'subscription_not_eligible' })
}
}
let invite =
await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId)
const currentUser = SessionManager.getSessionUser(req.session)
if (invite === null) {
invite = await CollaboratorsInviteHandler.promises.createSharingLinkInvite(
projectId,
@@ -574,19 +574,22 @@ const _ProjectController = {
)
).isMember)()
: false,
activeGroupSubscriptions:
SubscriptionLocator.promises.getUserActiveGroupSubscriptions(userId, {
_id: 1,
teamName: 1,
sharingPermissions: 1,
}),
activeProfessionalGroupSubscriptions:
SubscriptionLocator.promises.getUserActiveProfessionalGroupSubscriptions(
userId,
{
_id: 1,
teamName: 1,
sharingPermissions: 1,
}
),
})
const {
project,
userValues,
userIsMemberOfGroupSubscription,
activeGroupSubscriptions,
activeProfessionalGroupSubscriptions,
} = responses
await Promise.all([
@@ -976,7 +979,7 @@ const _ProjectController = {
),
isMemberOfGroupSubscription: userIsMemberOfGroupSubscription,
hasInstitutionLicence: userHasInstitutionLicence,
activeGroupSubscriptions,
activeProfessionalGroupSubscriptions,
},
initialLoadingScreenTheme,
userSettings,
@@ -7,6 +7,7 @@ import logger from '@overleaf/logger'
import { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } from './AiHelper.mjs'
import './GroupPlansData.mjs' // make sure dynamic group plans are loaded
import Features from '../../infrastructure/Features.mjs'
import { isProfessionalGroupPlan } from './PlansHelper.mjs'
const SubscriptionLocator = {
async getUsersSubscription(userOrId) {
@@ -180,11 +181,14 @@ const SubscriptionLocator = {
}
},
async getUserActiveGroupSubscriptions(userOrId, projection = {}) {
async getUserActiveProfessionalGroupSubscriptions(userOrId, projection = {}) {
if (!Features.hasFeature('saas')) return []
const userId = SubscriptionLocator._getUserId(userOrId)
return await Subscription.find(
if (!userId) return []
const activeGroupSubscriptions = await Subscription.find(
{
groupPlan: true,
$and: [
@@ -197,8 +201,16 @@ const SubscriptionLocator = {
},
],
},
projection
{
...projection,
groupPlan: 1,
planCode: 1,
}
).exec()
return activeGroupSubscriptions.filter(subscription =>
isProfessionalGroupPlan(subscription)
)
},
async getUserSubscriptionStatus(userId) {
@@ -31,6 +31,7 @@ import { ExcludeStrict } from '@ol-types/utils'
import getMeta from '@/utils/meta'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { debugConsole } from '@/utils/debugging'
type ProjectAccessProps = {
setIsInvitedPeopleScreen: React.Dispatch<React.SetStateAction<boolean>>
@@ -51,7 +52,7 @@ function ProjectAccess({
null
)
const { isProjectOwner } = useEditorContext()
const { activeGroupSubscriptions } = getMeta('ol-user')
const { activeProfessionalGroupSubscriptions } = getMeta('ol-user')
const groupSharingEnabled = useFeatureFlag('group-link-sharing')
const {
@@ -92,16 +93,18 @@ function ProjectAccess({
}
return data
}).then(data => {
setSharingLinkData(data)
setProjectAccess(newAccess)
setSuccessActionMessage(t('access_updated'))
sendMB('sharing-link-set-permissions', {
project_id: projectId,
access_level: newAccess.split('.')[0],
...reqBody,
})
})
.then(data => {
setSharingLinkData(data)
setProjectAccess(newAccess)
setSuccessActionMessage(t('access_updated'))
sendMB('sharing-link-set-permissions', {
project_id: projectId,
access_level: newAccess.split('.')[0],
...reqBody,
})
})
.catch(debugConsole.error)
}
const onAccessSelect = (eventKey: ProjectAccessType) => {
@@ -125,27 +128,31 @@ function ProjectAccess({
privileges: eventKey,
subscriptionId: sharingLinkData?.subscriptionId,
})
).then(data => {
setSharingLinkData(data)
setSuccessActionMessage(t('access_updated'))
sendMB('sharing-link-set-permissions', {
project_id: projectId,
access_level: projectAccess?.split('.')[0],
privileges: eventKey,
subscriptionId: data.subscriptionId,
)
.then(data => {
setSharingLinkData(data)
setSuccessActionMessage(t('access_updated'))
sendMB('sharing-link-set-permissions', {
project_id: projectId,
access_level: projectAccess?.split('.')[0],
privileges: eventKey,
subscriptionId: data.subscriptionId,
})
})
})
.catch(debugConsole.error)
}
const getGroupLinkText = (id?: string) => {
if (
!id ||
!activeGroupSubscriptions ||
activeGroupSubscriptions.length === 0
!activeProfessionalGroupSubscriptions ||
activeProfessionalGroupSubscriptions.length === 0
) {
return ''
}
const subscription = activeGroupSubscriptions.find(sub => sub._id === id)
const subscription = activeProfessionalGroupSubscriptions.find(
sub => sub._id === id
)
if (subscription?.teamName) {
return t('anyone_in_x_with_the_link', {
groupName: subscription.teamName,
@@ -252,8 +259,8 @@ function ProjectAccess({
</DropdownItem>
</DropdownListItem>
{groupSharingEnabled &&
activeGroupSubscriptions &&
activeGroupSubscriptions.map(subscription => (
activeProfessionalGroupSubscriptions &&
activeProfessionalGroupSubscriptions.map(subscription => (
<DropdownListItem
className="d-flex align-items-center"
key={subscription._id}
@@ -203,21 +203,18 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
setSuccessActionMessage(undefined)
setInFlight(true)
const promise = request()
promise.catch((error: { data?: Record<string, string> }) => {
setError(
error.data?.errorReason ||
error.data?.error ||
'generic_something_went_wrong'
)
})
promise.finally(() => {
setInFlight(false)
})
return promise
return request()
.catch((error: { data?: Record<string, string> }) => {
setError(
error.data?.errorReason ||
error.data?.error ||
'generic_something_went_wrong'
)
throw error
})
.finally(() => {
setInFlight(false)
})
}, [])
if (!project) {
@@ -1086,6 +1086,9 @@ describe('ProjectInviteTests', function () {
const subscription = new Subscription({
adminId: this.sendingUser._id,
memberIds: [this.sendingUser._id],
groupPlan: true,
planCode: 'group_professional',
paymentProvider: { state: 'active' },
})
let sharingLink
Async.series(
@@ -1102,7 +1102,7 @@ describe('<ShareProjectModal/>', function () {
user: {
id: USER_ID,
email: USER_EMAIL,
activeGroupSubscriptions: [{ _id: 'sub-123' }],
activeProfessionalGroupSubscriptions: [{ _id: 'sub-123' }],
},
})
@@ -75,7 +75,7 @@ export type EditorProvidersProps = {
| 'id'
| 'email'
| 'signUpDate'
| 'activeGroupSubscriptions'
| 'activeProfessionalGroupSubscriptions'
| 'isProfessionalGroupPlan'
>
projectId?: string
@@ -149,6 +149,12 @@ describe('CollaboratorsInviteController', function () {
},
}
ctx.SubscriptionLocator = {
promises: {
getUserActiveProfessionalGroupSubscriptions: sinon.stub().resolves([]),
},
}
ctx.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
@@ -257,6 +263,13 @@ describe('CollaboratorsInviteController', function () {
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator.mjs',
() => ({
default: ctx.SubscriptionLocator,
})
)
ctx.CollaboratorsInviteController = (await import(MODULE_PATH)).default
ctx.res = new MockResponse(vi)
@@ -1916,6 +1929,53 @@ describe('CollaboratorsInviteController', function () {
expect(ctx.invite.save).to.have.been.calledOnce
expect(ctx.res.json).toHaveBeenCalledTimes(1)
})
describe('with a subscriptionId', function () {
beforeEach(function (ctx) {
ctx.subscriptionId = new ObjectId()
ctx.req.body.subscriptionId = ctx.subscriptionId.toString()
})
it('creates the invite when the user is in a matching professional group subscription', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionLocator.promises.getUserActiveProfessionalGroupSubscriptions.resolves(
[{ _id: ctx.subscriptionId }]
)
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
null
)
ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.resolves(
ctx.invite
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res)
})
ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.should.have.been.calledWith(
ctx.projectId,
'readOnly',
ctx.subscriptionId.toString()
)
})
it('responds with a 403 JSON error when the subscription is not a professional group the user belongs to', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionLocator.promises.getUserActiveProfessionalGroupSubscriptions.resolves(
[]
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.statusCode).to.equal(403)
expect(ctx.res.json).toHaveBeenCalledWith({
errorReason: 'subscription_not_eligible',
})
ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.called.should.equal(
false
)
})
})
})
describe('validateSharingLink', function () {
@@ -71,7 +71,7 @@ describe('ProjectController', function () {
ctx.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(),
getUserActiveGroupSubscriptions: sinon.stub().resolves(),
getUserActiveProfessionalGroupSubscriptions: sinon.stub().resolves(),
},
}
ctx.SubscriptionController = {
@@ -48,6 +48,12 @@ describe('Subscription Locator Tests', function () {
SSOConfig: ctx.SSOConfig,
}))
ctx.isProfessionalGroupPlan = sinon.stub()
vi.doMock('../../../../app/src/Features/Subscription/PlansHelper', () => ({
isProfessionalGroupPlan: subscription =>
ctx.isProfessionalGroupPlan(subscription),
}))
ctx.SubscriptionLocator = (await import(modulePath)).default
})
@@ -193,4 +199,58 @@ describe('Subscription Locator Tests', function () {
expect(subscriptionStatus).to.deep.equal({ personal: true, group: true })
})
})
describe('getUserActiveProfessionalGroupSubscriptions', function () {
beforeEach(function (ctx) {
ctx.standardSubscription = {
_id: 'standard-sub',
groupPlan: true,
planCode: 'group_standard',
}
ctx.professionalSubscription = {
_id: 'professional-sub',
groupPlan: true,
planCode: 'group_professional',
}
ctx.Subscription.find.returns({
exec: sinon
.stub()
.resolves([ctx.standardSubscription, ctx.professionalSubscription]),
})
ctx.isProfessionalGroupPlan.callsFake(
subscription => subscription === ctx.professionalSubscription
)
})
it('returns only the professional group subscriptions', async function (ctx) {
const result =
await ctx.SubscriptionLocator.promises.getUserActiveProfessionalGroupSubscriptions(
ctx.user._id
)
expect(result).to.deep.equal([ctx.professionalSubscription])
})
it('always requests `planCode` and `groupPlan` so professional status can be determined', async function (ctx) {
await ctx.SubscriptionLocator.promises.getUserActiveProfessionalGroupSubscriptions(
ctx.user._id,
{ _id: 1, teamName: 1 }
)
const projection = ctx.Subscription.find.lastCall.args[1]
expect(projection).to.include({
_id: 1,
teamName: 1,
planCode: 1,
groupPlan: 1,
})
})
it('returns an empty list without querying when no userId is provided', async function (ctx) {
const result =
await ctx.SubscriptionLocator.promises.getUserActiveProfessionalGroupSubscriptions(
undefined
)
expect(result).to.deep.equal([])
expect(ctx.Subscription.find.called).to.equal(false)
})
})
})
+1 -1
View File
@@ -65,7 +65,7 @@ export type User = {
isMemberOfGroupSubscription?: boolean
isProfessionalGroupPlan?: boolean
hasInstitutionLicence?: boolean
activeGroupSubscriptions?: {
activeProfessionalGroupSubscriptions?: {
_id: string
teamName?: string
sharingPermissions?: SharingPermissions