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', }) })