diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
index 60b65ed510..bcc4bf92db 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
@@ -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()
diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs
index f3929be64c..982997fc99 100644
--- a/services/web/app/src/Features/Project/ProjectController.mjs
+++ b/services/web/app/src/Features/Project/ProjectController.mjs
@@ -498,6 +498,7 @@ const _ProjectController = {
'overleaf-code',
'export-docx',
'sharing-updates',
+ 'sharing-updates-new-link',
'export-markdown',
'export-html',
'command-palette',
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 229002342b..390149bcfa 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -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": "",
diff --git a/services/web/frontend/js/features/share-project-modal/components/project-access.tsx b/services/web/frontend/js/features/share-project-modal/components/project-access.tsx
index 15bb24ea66..4287fafe7e 100644
--- a/services/web/frontend/js/features/share-project-modal/components/project-access.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/project-access.tsx
@@ -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({
- {projectAccess === 'legacyLinkSharing' && (
+ {newLinkEnabled && projectAccess === 'legacyLinkSharing' && (
<>
- {groupSharingEnabled &&
- activeProfessionalGroupSubscriptions &&
- activeProfessionalGroupSubscriptions.map(subscription => (
-
+ }
+ trailingIcon={
+ projectAccess === 'legacyLinkSharing'
+ ? 'check'
+ : undefined
+ }
+ active={projectAccess === 'legacyLinkSharing'}
>
+ {t('via_sharing_links')}
+
+
+ )}
+ {newLinkEnabled && (
+ <>
+ {groupSharingEnabled &&
+ activeProfessionalGroupSubscriptions &&
+ activeProfessionalGroupSubscriptions.map(subscription => (
+
+
+ }
+ trailingIcon={
+ projectAccess ===
+ `anyoneInXyzWithTheLink.${subscription._id}`
+ ? 'check'
+ : undefined
+ }
+ active={
+ projectAccess ===
+ `anyoneInXyzWithTheLink.${subscription._id}`
+ }
+ >
+ {t('anyone_in_x_with_the_link', {
+ groupName: subscription.teamName || 'your group',
+ })}
+
+
+ ))}
+
}
+ eventKey="anyoneWithTheLink"
+ leadingIcon={}
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')}
- ))}
-
- }
- trailingIcon={
- projectAccess === 'anyoneWithTheLink'
- ? 'check'
- : undefined
- }
- active={projectAccess === 'anyoneWithTheLink'}
- >
- {t('anyone_with_the_link')}
-
-
+ >
+ )}
{pendingAccess && (
@@ -311,48 +364,52 @@ function ProjectAccess({
/>
)}
- {projectAccess !== 'legacyLinkSharing' && privileges && (
-
-
-
-
-
-
- }
- active={privileges === 'readAndWrite'}
- trailingIcon={
- privileges === 'readAndWrite' ? 'check' : undefined
- }
+ {newLinkEnabled &&
+ projectAccess !== 'legacyLinkSharing' &&
+ privileges && (
+
+
- {t('editor')}
-
- }
- active={privileges === 'review'}
- trailingIcon={privileges === 'review' ? 'check' : undefined}
- >
- {t('reviewer')}
-
- }
- active={privileges === 'readOnly'}
- trailingIcon={privileges === 'readOnly' ? 'check' : undefined}
- >
- {t('viewer')}
-
-
-
- )}
+
+
+
+
+ }
+ active={privileges === 'readAndWrite'}
+ trailingIcon={
+ privileges === 'readAndWrite' ? 'check' : undefined
+ }
+ >
+ {t('editor')}
+
+ }
+ active={privileges === 'review'}
+ trailingIcon={privileges === 'review' ? 'check' : undefined}
+ >
+ {t('reviewer')}
+
+ }
+ active={privileges === 'readOnly'}
+ trailingIcon={
+ privileges === 'readOnly' ? 'check' : undefined
+ }
+ >
+ {t('viewer')}
+
+
+
+ )}
)}
diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx
index 9e3a5fcbed..b724b05f82 100644
--- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx
@@ -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({
{isSharingUpdatesEnabled ? (
<>
- {!isInvitedPeopleScreen &&
+ {isNewLinkEnabled &&
+ !isInvitedPeopleScreen &&
projectAccess &&
(projectAccess === 'onlyInvitedPeople' ||
projectAccess.startsWith('anyoneInXyzWithTheLink') ||
diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
index 7ee00ec665..a17f41b0d7 100644
--- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
@@ -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(() => {
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 9714f48750..a9fe621591 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -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",
diff --git a/services/web/test/acceptance/config/settings.test.server-ce.js b/services/web/test/acceptance/config/settings.test.server-ce.js
index 87a3294ecc..ffeef58a28 100644
--- a/services/web/test/acceptance/config/settings.test.server-ce.js
+++ b/services/web/test/acceptance/config/settings.test.server-ce.js
@@ -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) {
diff --git a/services/web/test/acceptance/src/ProjectInviteTests.mjs b/services/web/test/acceptance/src/ProjectInviteTests.mjs
index 6fcb86e38a..935c4779c1 100644
--- a/services/web/test/acceptance/src/ProjectInviteTests.mjs
+++ b/services/web/test/acceptance/src/ProjectInviteTests.mjs
@@ -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) => {
diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx
index 77f2fccd90..6e01310e43 100644
--- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx
+++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx
@@ -1042,6 +1042,7 @@ describe('', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'sharing-updates': 'enabled',
+ 'sharing-updates-new-link': 'enabled',
})
})
@@ -1286,10 +1287,70 @@ describe('', 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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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',
})
})