[web] Gate new reusable sharing links behind sharing-updates-new-link (#35412)

Ship the new share-project modal (behind sharing-updates) wired to the
legacy token-based link sharing, and hide the new reusable-link
functionality (anyone with the link / group links) behind a new
sharing-updates-new-link split test.

- With the flag off, the modal offers two options, "Only invited people"
  and "Via sharing links", both driven by publicAccessLevel; the reusable
  -link options, privileges dropdown and copy-sharing-link button are hidden.
- Enable the flag in the acceptance tests that exercise those routes: a
  seeded split test in saas, splitTestOverrides in non-saas.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomas- <2176518+thomas-@users.noreply.github.com>
GitOrigin-RevId: 3f34a2da5dbdc6f27ed389f9c25eea66143b8160
This commit is contained in:
Thomas
2026-09-17 11:27:06 +00:00
committed by Copybot
co-authored by Claude Opus 4.8 copilot-swe-agent[bot] thomas-
parent e0d77271c2
commit b8b78b6932
10 changed files with 287 additions and 78 deletions
@@ -5,6 +5,7 @@ import CollaboratorsInviteController from './CollaboratorsInviteController.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.mjs'
import SplitTestMiddleware from '../SplitTests/SplitTestMiddleware.mjs'
const rateLimiters = {
inviteToProjectByProjectId: new RateLimiter(
@@ -98,6 +99,9 @@ export default {
webRouter.get(
'/project/:Project_id/sharing-link',
AuthenticationController.requireLogin(),
SplitTestMiddleware.ensureSplitTestEnabledForUser(
'sharing-updates-new-link'
),
AuthorizationMiddleware.ensureUserCanAdminProject,
CollaboratorsInviteController.getSharingLink
)
@@ -105,6 +109,9 @@ export default {
webRouter.post(
'/project/:Project_id/sharing-link',
AuthenticationController.requireLogin(),
SplitTestMiddleware.ensureSplitTestEnabledForUser(
'sharing-updates-new-link'
),
AuthorizationMiddleware.ensureUserCanAdminProject,
CollaboratorsInviteController.updateSharingLink
)
@@ -155,6 +162,9 @@ export default {
'collaboration',
'project-invite'
),
SplitTestMiddleware.ensureSplitTestEnabledForUser(
'sharing-updates-new-link'
),
RateLimiterMiddleware.rateLimit(rateLimiters.viewProjectInvite),
CollaboratorsInviteController.viewSharingLink,
AnalyticsRegistrationSourceMiddleware.clearSource()
@@ -167,6 +177,9 @@ export default {
'project-invite'
),
AuthenticationController.requireLogin(),
SplitTestMiddleware.ensureSplitTestEnabledForUser(
'sharing-updates-new-link'
),
RateLimiterMiddleware.rateLimit(rateLimiters.acceptProjectInvite),
CollaboratorsInviteController.acceptInvite,
AnalyticsRegistrationSourceMiddleware.clearSource()
@@ -178,6 +191,9 @@ export default {
'collaboration',
'project-invite'
),
SplitTestMiddleware.ensureSplitTestEnabledForUser(
'sharing-updates-new-link'
),
RateLimiterMiddleware.rateLimit(rateLimiters.validateSharingLink),
CollaboratorsInviteController.validateSharingLink,
AnalyticsRegistrationSourceMiddleware.clearSource()
@@ -498,6 +498,7 @@ const _ProjectController = {
'overleaf-code',
'export-docx',
'sharing-updates',
'sharing-updates-new-link',
'export-markdown',
'export-html',
'command-palette',
@@ -2367,6 +2367,7 @@
"verify_email_address_before_enabling_managed_users": "",
"verify_your_email_address": "",
"verifying_domain": "",
"via_sharing_links": "",
"via_sharing_links_legacy": "",
"view": "",
"view_all": "",
@@ -54,6 +54,10 @@ function ProjectAccess({
const { isProjectOwner } = useEditorContext()
const { activeProfessionalGroupSubscriptions } = getMeta('ol-user')
const groupSharingEnabled = useFeatureFlag('group-link-sharing')
// When the new reusable-link feature is off, the modal only offers the
// legacy token-based link ("Via sharing links") alongside "Only invited
// people", both driven directly by the project's publicAccessLevel.
const newLinkEnabled = useFeatureFlag('sharing-updates-new-link')
const {
monitorRequest,
@@ -107,7 +111,31 @@ function ProjectAccess({
.catch(debugConsole.error)
}
// When the new reusable-link feature is off, both options map straight to a
// project publicAccessLevel change (legacy token-based link sharing), without
// touching the reusable sharing-link invite.
const handleLegacyAccessChange = (newAccess: ProjectAccessType) => {
const publicAccessLevel =
newAccess === 'legacyLinkSharing' ? 'tokenBased' : 'private'
monitorRequest(() => setPublicAccessLevel(projectId, publicAccessLevel))
.then(() => {
setProjectAccess(newAccess)
setSuccessActionMessage(t('access_updated'))
sendMB('sharing-link-set-permissions', {
project_id: projectId,
access_level: publicAccessLevel,
})
})
.catch(debugConsole.error)
}
const onAccessSelect = (eventKey: ProjectAccessType) => {
if (!newLinkEnabled) {
// Legacy token-based sharing only: no reusable-link invite, no confirmation
handleLegacyAccessChange(eventKey)
return
}
if (
projectAccess === 'legacyLinkSharing' &&
eventKey !== 'legacyLinkSharing'
@@ -167,7 +195,9 @@ function ProjectAccess({
const [accessType, subscriptionId] = projectAccess.split('.')
switch (accessType) {
case 'legacyLinkSharing':
return t('via_sharing_links_legacy')
return newLinkEnabled
? t('via_sharing_links_legacy')
: t('via_sharing_links')
case 'onlyInvitedPeople':
return t('only_invited_people')
case 'anyoneInXyzWithTheLink':
@@ -223,7 +253,7 @@ function ProjectAccess({
<MaterialIcon type="keyboard_arrow_down" />
</OLDropdownToggle>
<OLDropdownMenu>
{projectAccess === 'legacyLinkSharing' && (
{newLinkEnabled && projectAccess === 'legacyLinkSharing' && (
<>
<DropdownListItem className="d-flex align-items-center">
<OLDropdownItem
@@ -258,49 +288,72 @@ function ProjectAccess({
{t('only_invited_people')}
</OLDropdownItem>
</DropdownListItem>
{groupSharingEnabled &&
activeProfessionalGroupSubscriptions &&
activeProfessionalGroupSubscriptions.map(subscription => (
<DropdownListItem
className="d-flex align-items-center"
key={subscription._id}
{!newLinkEnabled && (
<DropdownListItem className="d-flex align-items-center">
<OLDropdownItem
as="button"
eventKey="legacyLinkSharing"
leadingIcon={<MaterialIcon type="link" />}
trailingIcon={
projectAccess === 'legacyLinkSharing'
? 'check'
: undefined
}
active={projectAccess === 'legacyLinkSharing'}
>
{t('via_sharing_links')}
</OLDropdownItem>
</DropdownListItem>
)}
{newLinkEnabled && (
<>
{groupSharingEnabled &&
activeProfessionalGroupSubscriptions &&
activeProfessionalGroupSubscriptions.map(subscription => (
<DropdownListItem
className="d-flex align-items-center"
key={subscription._id}
>
<OLDropdownItem
as="button"
eventKey={`anyoneInXyzWithTheLink.${subscription._id}`}
leadingIcon={
<MaterialIcon type="domain" unfilled />
}
trailingIcon={
projectAccess ===
`anyoneInXyzWithTheLink.${subscription._id}`
? 'check'
: undefined
}
active={
projectAccess ===
`anyoneInXyzWithTheLink.${subscription._id}`
}
>
{t('anyone_in_x_with_the_link', {
groupName: subscription.teamName || 'your group',
})}
</OLDropdownItem>
</DropdownListItem>
))}
<DropdownListItem className="d-flex align-items-center gap-2">
<OLDropdownItem
as="button"
eventKey={`anyoneInXyzWithTheLink.${subscription._id}`}
leadingIcon={<MaterialIcon type="domain" unfilled />}
eventKey="anyoneWithTheLink"
leadingIcon={<MaterialIcon type="globe" unfilled />}
trailingIcon={
projectAccess ===
`anyoneInXyzWithTheLink.${subscription._id}`
projectAccess === 'anyoneWithTheLink'
? 'check'
: undefined
}
active={
projectAccess ===
`anyoneInXyzWithTheLink.${subscription._id}`
}
active={projectAccess === 'anyoneWithTheLink'}
>
{t('anyone_in_x_with_the_link', {
groupName: subscription.teamName || 'your group',
})}
{t('anyone_with_the_link')}
</OLDropdownItem>
</DropdownListItem>
))}
<DropdownListItem className="d-flex align-items-center gap-2">
<OLDropdownItem
as="button"
eventKey="anyoneWithTheLink"
leadingIcon={<MaterialIcon type="globe" unfilled />}
trailingIcon={
projectAccess === 'anyoneWithTheLink'
? 'check'
: undefined
}
active={projectAccess === 'anyoneWithTheLink'}
>
{t('anyone_with_the_link')}
</OLDropdownItem>
</DropdownListItem>
</>
)}
</OLDropdownMenu>
</OLDropdown>
{pendingAccess && (
@@ -311,48 +364,52 @@ function ProjectAccess({
/>
)}
</div>
{projectAccess !== 'legacyLinkSharing' && privileges && (
<OLDropdown align="end" onSelect={onPrivilegesChange}>
<OLDropdownToggle
variant="ghost"
className="d-flex align-items-center gap-2 no-default-caret"
>
<MemberPrivileges privileges={privileges} />
<MaterialIcon type="keyboard_arrow_down" />
</OLDropdownToggle>
<OLDropdownMenu>
<DropdownMenuItem
as="button"
eventKey="readAndWrite"
leadingIcon={<MaterialIcon type="edit" unfilled />}
active={privileges === 'readAndWrite'}
trailingIcon={
privileges === 'readAndWrite' ? 'check' : undefined
}
{newLinkEnabled &&
projectAccess !== 'legacyLinkSharing' &&
privileges && (
<OLDropdown align="end" onSelect={onPrivilegesChange}>
<OLDropdownToggle
variant="ghost"
className="d-flex align-items-center gap-2 no-default-caret"
>
{t('editor')}
</DropdownMenuItem>
<DropdownMenuItem
as="button"
eventKey="review"
leadingIcon={<MaterialIcon type="mode_comment" unfilled />}
active={privileges === 'review'}
trailingIcon={privileges === 'review' ? 'check' : undefined}
>
{t('reviewer')}
</DropdownMenuItem>
<DropdownMenuItem
as="button"
eventKey="readOnly"
leadingIcon={<MaterialIcon type="visibility" unfilled />}
active={privileges === 'readOnly'}
trailingIcon={privileges === 'readOnly' ? 'check' : undefined}
>
{t('viewer')}
</DropdownMenuItem>
</OLDropdownMenu>
</OLDropdown>
)}
<MemberPrivileges privileges={privileges} />
<MaterialIcon type="keyboard_arrow_down" />
</OLDropdownToggle>
<OLDropdownMenu>
<DropdownMenuItem
as="button"
eventKey="readAndWrite"
leadingIcon={<MaterialIcon type="edit" unfilled />}
active={privileges === 'readAndWrite'}
trailingIcon={
privileges === 'readAndWrite' ? 'check' : undefined
}
>
{t('editor')}
</DropdownMenuItem>
<DropdownMenuItem
as="button"
eventKey="review"
leadingIcon={<MaterialIcon type="mode_comment" unfilled />}
active={privileges === 'review'}
trailingIcon={privileges === 'review' ? 'check' : undefined}
>
{t('reviewer')}
</DropdownMenuItem>
<DropdownMenuItem
as="button"
eventKey="readOnly"
leadingIcon={<MaterialIcon type="visibility" unfilled />}
active={privileges === 'readOnly'}
trailingIcon={
privileges === 'readOnly' ? 'check' : undefined
}
>
{t('viewer')}
</DropdownMenuItem>
</OLDropdownMenu>
</OLDropdown>
)}
</ShareProjectModalRow>
)}
@@ -74,6 +74,10 @@ function ShareProjectModalContentInner({
>) {
const { t } = useTranslation()
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
// The copy-sharing-link button only applies to the new reusable links; legacy
// token links are copied from their own inputs, so hide it when the new-link
// feature is off.
const isNewLinkEnabled = useFeatureFlag('sharing-updates-new-link')
const [isInvitedPeopleScreen, setIsInvitedPeopleScreen] = useState(false)
const { successActionMessage, projectAccess } = useShareProjectContext()
const { isRestrictedTokenMember, isProjectOwner } = useEditorContext()
@@ -136,7 +140,8 @@ function ShareProjectModalContentInner({
<div className="d-flex flex-grow-1 flex-wrap gap-2">
{isSharingUpdatesEnabled ? (
<>
{!isInvitedPeopleScreen &&
{isNewLinkEnabled &&
!isInvitedPeopleScreen &&
projectAccess &&
(projectAccess === 'onlyInvitedPeople' ||
projectAccess.startsWith('anyoneInXyzWithTheLink') ||
@@ -97,6 +97,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
const { splitTestVariants } = useSplitTestContext()
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const isNewLinkEnabled = useFeatureFlag('sharing-updates-new-link')
// show the new share modal if project owner
// is over collaborator limit or has pending editors (once every 24 hours)
@@ -155,6 +156,19 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
return
}
// When the new reusable-link feature is off, derive access purely from the
// project's publicAccessLevel (legacy token-based sharing) and avoid the
// reusable sharing-link endpoint, which is gated behind the same flag.
if (!isNewLinkEnabled) {
setSharingLinkData(null)
setProjectAccess(
publicAccessLevel === 'tokenBased'
? 'legacyLinkSharing'
: 'onlyInvitedPeople'
)
return
}
if (publicAccessLevel === 'tokenBased') {
setSharingLinkData(null)
setProjectAccess('legacyLinkSharing')
@@ -187,7 +201,13 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
'generic_something_went_wrong'
)
}
}, [publicAccessLevel, projectId, isSharingUpdatesEnabled, isProjectOwner])
}, [
publicAccessLevel,
projectId,
isSharingUpdatesEnabled,
isNewLinkEnabled,
isProjectOwner,
])
// close the modal if not in flight
const cancel = useCallback(() => {
+1
View File
@@ -3010,6 +3010,7 @@
"verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
"verify_your_email_address": "Verify your email address",
"verifying_domain": "Verifying domain",
"via_sharing_links": "Via sharing links",
"via_sharing_links_legacy": "Via sharing links (legacy)",
"via_support": "Via Support",
"view": "View",
@@ -5,6 +5,10 @@ const base = require('./settings.test.defaults')
module.exports = base.mergeWith({
defaultFeatures: ServerCEDefaults.defaultFeatures,
activeUserMetricInterval: 100,
splitTestOverrides: {
'sharing-updates-new-link': 'enabled', // routes in acceptance tests
},
})
module.exports.mergeWith = function (overrides) {
@@ -6,10 +6,44 @@ import settings from '@overleaf/settings'
import CollaboratorsEmailHandler from '../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs'
import CollaboratorsInviteHelper from '../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs'
import Features from '../../../app/src/infrastructure/Features.mjs'
import { db } from '../../../app/src/infrastructure/mongodb.mjs'
import { CacheFlow } from 'cache-flow'
import sinon from 'sinon'
let generateTokenSpy
// The reusable sharing-link routes are gated behind the `sharing-updates-new-link`
// split test. In saas, assignments come from the database, so seed a fully
// rolled-out "enabled" version. In non-saas, splitTestOverrides in the test
// settings handle it (see settings.test.defaults.js).
const enableNewLinkSplitTest = async () => {
await db.splittests.updateOne(
{ name: 'sharing-updates-new-link' },
{
$set: {
versions: [
{
versionNumber: 1,
createdAt: new Date(),
active: true,
analyticsEnabled: false,
phase: 'release',
variants: [
{
name: 'enabled',
rolloutPercent: 100,
rolloutStripes: [{ start: 0, end: 100 }],
},
],
},
],
},
},
{ upsert: true }
)
await CacheFlow.reset('split-test')
}
const createInvite = (sendingUser, projectId, email, callback) => {
sendingUser.getCsrfToken(err => {
if (err) {
@@ -947,6 +981,15 @@ describe('ProjectInviteTests', function () {
})
describe('sharing link routes', function () {
beforeEach(async function () {
// In saas, split-test assignments come from the database. In non-saas the
// splitTestOverrides in the test settings handle it, and the splittests
// collection has no index on `name` (so a seed write hits notablescan).
if (Features.hasFeature('saas')) {
await enableNewLinkSplitTest()
}
})
beforeEach(function (done) {
this.projectName = `sharing-link-test-${Math.random()}`
createProject(this.sendingUser, this.projectName, (err, projectId) => {
@@ -1042,6 +1042,7 @@ describe('<ShareProjectModal/>', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'sharing-updates': 'enabled',
'sharing-updates-new-link': 'enabled',
})
})
@@ -1286,10 +1287,70 @@ describe('<ShareProjectModal/>', function () {
expect(screen.queryByRole('link', { name: 'Give feedback' })).to.be.null
})
describe('sharing-updates enabled, new sharing links disabled', function () {
// The new reusable-link functionality is gated behind
// `sharing-updates-new-link`. With only `sharing-updates` on, the modal
// shows the new UI wired to legacy token-based link sharing: two options
// ("Only invited people" / "Via sharing links") driven by publicAccessLevel.
beforeEach(function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'sharing-updates': 'enabled',
})
})
afterEach(function () {
window.metaAttributesCache.delete('ol-splitTestVariants')
})
it('shows "Via sharing links" without the legacy label when `publicAccessLevel` is `tokenBased`', async function () {
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
await screen.findByText('Via sharing links')
expect(screen.queryByText('Via sharing links (legacy)')).to.be.null
})
it('shows "Only invited people" when `publicAccessLevel` is `private`, without calling the sharing-link endpoint', async function () {
// Deliberately not mocking /sharing-link: it must not be requested when
// the new-link flag is off (fetch-mock throws on unmatched routes).
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'private' })
)
await screen.findByText('Only invited people')
// The copy-sharing-link button is for the new reusable links only.
expect(screen.queryByRole('button', { name: 'Copy sharing link' })).to.be
.null
})
it('offers only the two legacy access options, not "Anyone with the link"', async function () {
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
const toggle = await screen.findByRole('button', {
name: 'Via sharing links',
})
await userEvent.click(toggle)
await screen.findByText('Only invited people')
expect(screen.queryByText('Anyone with the link')).to.be.null
})
})
describe('with "sharing-updates" feature flag', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'sharing-updates': 'enabled',
'sharing-updates-new-link': 'enabled',
})
})