Просмотр исходного кода

Add new interstitial 'Join project' consent page for existing link sharing editors when opening a project (#19066)

* Add helpers for checking and removing user readwrite token membership

* Add sharing-updates page and handlers

* Redirect read write token members to sharing-updates on project load

GitOrigin-RevId: d552a2cd74a9843c6103923b03f137131a48877a
Thomas 2 лет назад
Родитель
Сommit
94be372b24

+ 34 - 0
services/web/app/src/Features/Collaborators/CollaboratorsGetter.js

@@ -42,8 +42,10 @@ module.exports = {
     getProjectsUserIsMemberOf,
     dangerouslyGetAllProjectsUserIsMemberOf,
     isUserInvitedMemberOfProject,
+    isUserInvitedReadWriteMemberOfProject,
     getPublicShareTokens,
     userIsTokenMember,
+    userIsReadWriteTokenMember,
     getAllInvitedMembers,
   },
 }
@@ -139,6 +141,23 @@ async function isUserInvitedMemberOfProject(userId, projectId) {
   return false
 }
 
+async function isUserInvitedReadWriteMemberOfProject(userId, projectId) {
+  if (!userId) {
+    return false
+  }
+  const members = await getMemberIdsWithPrivilegeLevels(projectId)
+  for (const member of members) {
+    if (
+      member.id.toString() === userId.toString() &&
+      member.source !== Sources.TOKEN &&
+      member.privilegeLevel === PrivilegeLevels.READ_AND_WRITE
+    ) {
+      return true
+    }
+  }
+  return false
+}
+
 async function getPublicShareTokens(userId, projectId) {
   const memberInfo = await Project.findOne(
     {
@@ -253,6 +272,21 @@ async function userIsTokenMember(userId, projectId) {
   return project != null
 }
 
+async function userIsReadWriteTokenMember(userId, projectId) {
+  userId = new ObjectId(userId.toString())
+  projectId = new ObjectId(projectId.toString())
+  const project = await Project.findOne(
+    {
+      _id: projectId,
+      tokenAccessReadAndWrite_refs: userId,
+    },
+    {
+      _id: 1,
+    }
+  ).exec()
+  return project != null
+}
+
 async function _getInvitedMemberCount(projectId) {
   const members = await getMemberIdsWithPrivilegeLevels(projectId)
   return members.filter(m => m.source !== Sources.TOKEN).length

+ 30 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -467,6 +467,36 @@ const _ProjectController = {
           anonRequestToken
         )
 
+      const linkSharingChanges =
+        await SplitTestHandler.promises.getAssignmentForUser(
+          project.owner_ref,
+          'link-sharing-warning'
+        )
+      if (linkSharingChanges?.variant === 'active') {
+        if (isTokenMember) {
+          // Check explicitly that the user is in read write token refs, while this could be inferred
+          // from the privilege level, the privilege level of token members might later be restricted
+          const isReadWriteTokenMember =
+            await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+              userId,
+              projectId
+            )
+          if (isReadWriteTokenMember) {
+            // Check for an edge case where a user is both in read write token access refs but also
+            // an invited read write member. Ensure they are not redirected to the sharing updates page
+            // We could also delete the token access ref if the user is already a member of the project
+            const isInvitedReadWriteMember =
+              await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+                userId,
+                projectId
+              )
+            if (!isInvitedReadWriteMember) {
+              return res.redirect(`/project/${projectId}/sharing-updates`)
+            }
+          }
+        }
+      }
+
       let allowedFreeTrial = true
 
       if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) {

+ 119 - 0
services/web/app/src/Features/TokenAccess/TokenAccessController.js

@@ -15,6 +15,9 @@ const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
 const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
 const EditorRealTimeController = require('../Editor/EditorRealTimeController')
+const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
+const ProjectGetter = require('../Project/ProjectGetter')
+const AsyncFormHelper = require('../Helpers/AsyncFormHelper')
 
 const orderedPrivilegeLevels = [
   PrivilegeLevels.NONE,
@@ -433,6 +436,116 @@ async function grantTokenAccessReadOnly(req, res, next) {
   }
 }
 
+async function ensureUserCanUseSharingUpdatesConsentPage(req, res, next) {
+  const { Project_id: projectId } = req.params
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const project = await ProjectGetter.promises.getProject(projectId, {
+    owner_ref: 1,
+  })
+  if (!project) {
+    throw new Errors.NotFoundError()
+  }
+  const linkSharingChanges =
+    await SplitTestHandler.promises.getAssignmentForUser(
+      project.owner_ref,
+      'link-sharing-warning'
+    )
+  if (linkSharingChanges?.variant !== 'active') {
+    return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
+  }
+  const isReadWriteTokenMember =
+    await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+      userId,
+      projectId
+    )
+  if (!isReadWriteTokenMember) {
+    // If the user is not a read write token member, there are no actions to take
+    return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
+  }
+  const isReadWriteMember =
+    await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+      userId,
+      projectId
+    )
+  if (isReadWriteMember) {
+    // If the user is already an invited editor, the actions don't make sense
+    return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
+  }
+  next()
+}
+
+async function sharingUpdatesConsent(req, res, next) {
+  const { Project_id: projectId } = req.params
+  res.render('project/token/sharing-updates', {
+    projectId,
+  })
+}
+
+async function moveReadWriteToCollaborators(req, res, next) {
+  const { Project_id: projectId } = req.params
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const isInvitedMember =
+    await CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
+      userId,
+      projectId
+    )
+  await ProjectAuditLogHandler.promises.addEntry(
+    projectId,
+    'accept-via-link-sharing',
+    userId,
+    req.ip,
+    {
+      privileges: 'readAndWrite',
+      tokenMember: true,
+      invitedMember: isInvitedMember,
+    }
+  )
+  if (isInvitedMember) {
+    // Read only invited viewer who is gaining edit access via link sharing
+    await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
+      userId,
+      projectId
+    )
+    await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+      projectId,
+      userId,
+      PrivilegeLevels.READ_AND_WRITE
+    )
+  } else {
+    // Normal case, not invited, joining via link sharing
+    await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
+      userId,
+      projectId
+    )
+    await CollaboratorsHandler.promises.addUserIdToProject(
+      projectId,
+      undefined,
+      userId,
+      PrivilegeLevels.READ_AND_WRITE
+    )
+  }
+  EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {
+    members: true,
+  })
+  res.sendStatus(204)
+}
+
+async function moveReadWriteToReadOnly(req, res, next) {
+  const { Project_id: projectId } = req.params
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  await ProjectAuditLogHandler.promises.addEntry(
+    projectId,
+    'readonly-via-sharing-updates',
+    userId,
+    req.ip
+  )
+  await TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
+    userId,
+    projectId
+  )
+  res.sendStatus(204)
+}
+
 module.exports = {
   READ_ONLY_TOKEN_PATTERN: TokenAccessHandler.READ_ONLY_TOKEN_PATTERN,
   READ_AND_WRITE_TOKEN_PATTERN: TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN,
@@ -440,4 +553,10 @@ module.exports = {
   tokenAccessPage: expressify(tokenAccessPage),
   grantTokenAccessReadOnly: expressify(grantTokenAccessReadOnly),
   grantTokenAccessReadAndWrite: expressify(grantTokenAccessReadAndWrite),
+  ensureUserCanUseSharingUpdatesConsentPage: expressify(
+    ensureUserCanUseSharingUpdatesConsentPage
+  ),
+  sharingUpdatesConsent: expressify(sharingUpdatesConsent),
+  moveReadWriteToCollaborators: expressify(moveReadWriteToCollaborators),
+  moveReadWriteToReadOnly: expressify(moveReadWriteToReadOnly),
 }

+ 29 - 0
services/web/app/src/Features/TokenAccess/TokenAccessHandler.js

@@ -187,6 +187,35 @@ const TokenAccessHandler = {
     ).exec()
   },
 
+  async removeReadAndWriteUserFromProject(userId, projectId) {
+    userId = new ObjectId(userId.toString())
+    projectId = new ObjectId(projectId.toString())
+
+    return await Project.updateOne(
+      {
+        _id: projectId,
+      },
+      {
+        $pull: { tokenAccessReadAndWrite_refs: userId },
+      }
+    ).exec()
+  },
+
+  async moveReadAndWriteUserToReadOnly(userId, projectId) {
+    userId = new ObjectId(userId.toString())
+    projectId = new ObjectId(projectId.toString())
+
+    return await Project.updateOne(
+      {
+        _id: projectId,
+      },
+      {
+        $pull: { tokenAccessReadAndWrite_refs: userId },
+        $addToSet: { tokenAccessReadOnly_refs: userId },
+      }
+    ).exec()
+  },
+
   grantSessionTokenAccess(req, projectId, token) {
     if (!req.session) {
       return

+ 31 - 0
services/web/app/src/Features/TokenAccess/TokenAccessRouter.js

@@ -0,0 +1,31 @@
+const AuthenticationController = require('../Authentication/AuthenticationController')
+const AuthorizationMiddleware = require('../Authorization/AuthorizationMiddleware')
+const TokenAccessController = require('./TokenAccessController')
+
+module.exports = {
+  apply(webRouter) {
+    webRouter.get(
+      `/project/:Project_id/sharing-updates`,
+      AuthenticationController.requireLogin(),
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage,
+      TokenAccessController.sharingUpdatesConsent
+    )
+
+    webRouter.post(
+      `/project/:Project_id/sharing-updates/join`,
+      AuthenticationController.requireLogin(),
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage,
+      TokenAccessController.moveReadWriteToCollaborators
+    )
+
+    webRouter.post(
+      `/project/:Project_id/sharing-updates/view`,
+      AuthenticationController.requireLogin(),
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage,
+      TokenAccessController.moveReadWriteToReadOnly
+    )
+  },
+}

+ 2 - 0
services/web/app/src/router.js

@@ -51,6 +51,7 @@ const BetaProgramController = require('./Features/BetaProgram/BetaProgramControl
 const AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 const MetaController = require('./Features/Metadata/MetaController')
 const TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
+const TokenAccessRouter = require('./Features/TokenAccess/TokenAccessRouter')
 const Features = require('./infrastructure/Features')
 const LinkedFilesRouter = require('./Features/LinkedFiles/LinkedFilesRouter')
 const TemplatesRouter = require('./Features/Templates/TemplatesRouter')
@@ -270,6 +271,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
   LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
   TemplatesRouter.apply(webRouter)
   UserMembershipRouter.apply(webRouter)
+  TokenAccessRouter.apply(webRouter)
 
   Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
 

+ 16 - 0
services/web/app/views/project/token/sharing-updates.pug

@@ -0,0 +1,16 @@
+extends ../../layout-marketing
+
+block entrypointVar
+	- entrypoint = 'pages/sharing-updates'
+
+block vars
+	- var suppressFooter = true
+	- var suppressCookieBanner = true
+	- var suppressSkipToContent = true
+
+block append meta
+	meta(name="ol-project_id" data-type="string" content=projectId)
+
+block content
+	.content.content-alt#main-content
+		div#sharing-updates-page

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -874,6 +874,7 @@
   "off": "",
   "official": "",
   "ok": "",
+  "ok_continue_to_project": "",
   "ok_join_project": "",
   "on": "",
   "on_free_plan_upgrade_to_access_features": "",
@@ -1529,6 +1530,7 @@
   "update_account_info": "",
   "update_dropbox_settings": "",
   "update_your_billing_details": "",
+  "updates_to_project_sharing": "",
   "updating": "",
   "upgrade": "",
   "upgrade_cc_btn": "",
@@ -1594,6 +1596,7 @@
   "we_sent_new_code": "",
   "wed_love_you_to_stay": "",
   "welcome_to_sl": "",
+  "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "",
   "were_performing_maintenance": "",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "",
@@ -1640,6 +1643,7 @@
   "you_are_on_a_paid_plan_contact_support_to_find_out_more": "",
   "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
+  "you_can_also_choose_to_view_anonymously_or_leave_the_project": "",
   "you_can_now_enable_sso": "",
   "you_can_now_log_in_sso": "",
   "you_can_only_add_n_people_to_edit_a_project": "",

+ 135 - 0
services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx

@@ -0,0 +1,135 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { Trans, useTranslation } from 'react-i18next'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
+import { useCallback } from 'react'
+import getMeta from '@/utils/meta'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import useAsync from '@/shared/hooks/use-async'
+import Notification from '@/shared/components/notification'
+
+function SharingUpdatesRoot() {
+  const { isReady } = useWaitForI18n()
+  const { t } = useTranslation()
+  const { isLoading, isSuccess, isError, runAsync } = useAsync()
+  const projectId = getMeta('ol-project_id')
+
+  const joinProject = useCallback(() => {
+    runAsync(postJSON(`/project/${projectId}/sharing-updates/join`))
+      .then(() => {
+        location.assign(`/project/${projectId}`)
+      })
+      .catch(debugConsole.error)
+  }, [runAsync, projectId])
+
+  const viewProject = useCallback(() => {
+    runAsync(postJSON(`/project/${projectId}/sharing-updates/view`))
+      .then(() => {
+        location.assign(`/project/${projectId}`)
+      })
+      .catch(debugConsole.error)
+  }, [runAsync, projectId])
+
+  const leaveProject = useCallback(() => {
+    runAsync(postJSON(`/project/${projectId}/leave`))
+      .then(() => {
+        location.assign('/project')
+      })
+      .catch(debugConsole.error)
+  }, [runAsync, projectId])
+
+  if (!isReady) {
+    return null
+  }
+
+  return (
+    <div className="container">
+      <div className="row">
+        <div className="col-md-6 col-md-offset-3">
+          <div className="card sharing-updates">
+            <div className="row">
+              <div className="col-md-12">
+                <h1 className="sharing-updates-h1">
+                  {t('updates_to_project_sharing')}
+                </h1>
+              </div>
+            </div>
+
+            <div className="row row-spaced">
+              <div className="col-md-12">
+                <p>
+                  <Trans
+                    i18nKey="were_making_some_changes_to_project_sharing_this_means_you_will_be_visible"
+                    components={[
+                      // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                      <a
+                        href="/blog/changes-to-project-sharing"
+                        rel="noopener noreferrer"
+                        target="_blank"
+                      />,
+                    ]}
+                  />
+                </p>
+              </div>
+            </div>
+
+            <div className="row row-spaced">
+              <div className="col-md-12">
+                <button
+                  className="btn btn-primary"
+                  onClick={() => joinProject()}
+                  disabled={isLoading}
+                >
+                  {t('ok_continue_to_project')}
+                </button>
+              </div>
+            </div>
+
+            {isError && (
+              <div className="row row-spaced">
+                <div className="col-md-12">
+                  <Notification
+                    type="error"
+                    content={t('generic_something_went_wrong')}
+                  />
+                </div>
+              </div>
+            )}
+
+            <div className="row row-spaced">
+              <div className="col-md-12">
+                <p>
+                  <small>
+                    <Trans
+                      i18nKey="you_can_also_choose_to_view_anonymously_or_leave_the_project"
+                      components={[
+                        // eslint-disable-next-line react/jsx-key
+                        <button
+                          className="btn btn-inline-link"
+                          onClick={() => viewProject()}
+                          disabled={isLoading || isSuccess}
+                        />,
+                        // eslint-disable-next-line react/jsx-key
+                        <button
+                          className="btn btn-inline-link"
+                          onClick={() => leaveProject()}
+                          disabled={isLoading || isSuccess}
+                        />,
+                      ]}
+                    />
+                  </small>
+                </p>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default withErrorBoundary(
+  SharingUpdatesRoot,
+  GenericErrorBoundaryFallback
+)

+ 11 - 0
services/web/frontend/js/pages/sharing-updates.tsx

@@ -0,0 +1,11 @@
+import './../utils/meta'
+import './../utils/webpack-public-path'
+import './../infrastructure/error-reporter'
+import './../i18n'
+import ReactDOM from 'react-dom'
+import SharingUpdatesRoot from '../features/token-access/components/sharing-updates-root'
+
+const element = document.getElementById('sharing-updates-page')
+if (element) {
+  ReactDOM.render(<SharingUpdatesRoot />, element)
+}

+ 14 - 0
services/web/frontend/stylesheets/app/invite.less

@@ -27,3 +27,17 @@
   font-size: var(--font-size-07);
   line-height: var(--line-height-06);
 }
+
+.sharing-updates {
+  font-family: 'Noto Sans', sans-serif;
+  h1 {
+    font-family: 'Noto Sans', sans-serif;
+    // heading-sm
+    font-size: var(--font-size-05);
+    line-height: var(--line-height-04);
+  }
+  small {
+    font-size: var(--font-size-02);
+    line-height: var(--line-height-02);
+  }
+}

+ 4 - 0
services/web/locales/en.json

@@ -1284,6 +1284,7 @@
   "off": "Off",
   "official": "Official",
   "ok": "OK",
+  "ok_continue_to_project": "OK, continue to project",
   "ok_join_project": "OK, join project",
   "on": "On",
   "on_free_plan_upgrade_to_access_features": "You are on the __appName__ Free plan. Upgrade to access these <0>Premium Features</0>",
@@ -2151,6 +2152,7 @@
   "update_account_info": "Update Account Info",
   "update_dropbox_settings": "Update Dropbox Settings",
   "update_your_billing_details": "Update Your Billing Details",
+  "updates_to_project_sharing": "Updates to project sharing",
   "updating": "Updating",
   "updating_site": "Updating Site",
   "upgrade": "Upgrade",
@@ -2231,6 +2233,7 @@
   "website_status": "Website status",
   "wed_love_you_to_stay": "We’d love you to stay",
   "welcome_to_sl": "Welcome to __appName__",
+  "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "We’re making some <0>changes to project sharing</0>. This means, as someone with edit access, your name and email address will be visible to the project owner and other editors.",
   "were_performing_maintenance": "We’re performing maintenance on Overleaf and you need to wait a moment. Sorry for any inconvenience. The editor will refresh automatically in __seconds__ seconds.",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "We’ve recently <0>reduced the compile timeout limit</0> on our free plan, which may have affected this project.",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "We’ve recently <0>reduced the compile timeout limit</0> on our free plan, which may have affected your project.",
@@ -2292,6 +2295,7 @@
   "you_are_on_a_paid_plan_contact_support_to_find_out_more": "You’re on an __appName__ Paid plan. <0>Contact support</0> to find out more.",
   "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "You are on our <0>__planName__</0> plan as a <1>confirmed member</1> of <1>__institutionName__</1>",
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__</0> plan as a <1>member</1> of the group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>",
+  "you_can_also_choose_to_view_anonymously_or_leave_the_project": "You can also choose to <0>view anonymously</0> (you will lose edit access) or <1>leave the project</1>.",
   "you_can_now_enable_sso": "You can now enable SSO on your Group settings page.",
   "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features</0>.",
   "you_can_only_add_n_people_to_edit_a_project": "You can only add X people to edit a project with you on your current plan. Upgrade to add more.",

+ 427 - 0
services/web/test/acceptance/src/TokenAccessTests.js

@@ -5,6 +5,8 @@ const request = require('./helpers/request')
 const settings = require('@overleaf/settings')
 const { db } = require('../../../app/src/infrastructure/mongodb')
 const expectErrorResponse = require('./helpers/expectErrorResponse')
+const SplitTestHandler = require('../../../app/src/Features/SplitTests/SplitTestHandler')
+const sinon = require('sinon')
 
 const tryEditorAccess = (user, projectId, test, callback) =>
   async.series(
@@ -237,6 +239,47 @@ const tryFetchProjectTokens = (user, projectId, callback) => {
   )
 }
 
+const trySharingUpdatesPage = (user, projectId, test, callback) =>
+  user.request.get(
+    `/project/${projectId}/sharing-updates`,
+    (error, response, body) => {
+      if (error != null) {
+        return callback(error)
+      }
+      test(response, body, projectId)
+      callback()
+    }
+  )
+
+const trySharingUpdatesJoin = (user, projectId, test, callback) =>
+  user.request.post(
+    `/project/${projectId}/sharing-updates/join`,
+    (error, response, body) => {
+      if (error != null) {
+        return callback(error)
+      }
+      test(response, body, projectId)
+      callback()
+    }
+  )
+
+const trySharingUpdatesView = (user, projectId, test, callback) =>
+  user.request.post(
+    `/project/${projectId}/sharing-updates/view`,
+    (error, response, body) => {
+      if (error != null) {
+        return callback(error)
+      }
+      test(response, body, projectId)
+      callback()
+    }
+  )
+
+const expectRedirectToProject = (response, body, projectId) => {
+  expect(response.statusCode).to.equal(302)
+  expect(response.headers.location).to.equal(`/project/${projectId}`)
+}
+
 describe('TokenAccess', function () {
   beforeEach(function (done) {
     this.timeout(90000)
@@ -1782,4 +1825,388 @@ describe('TokenAccess', function () {
       )
     })
   })
+
+  describe('link sharing changes', function () {
+    beforeEach(function () {
+      this.getAssignmentForUser = sinon.stub(
+        SplitTestHandler.promises,
+        'getAssignmentForUser'
+      )
+      this.getAssignmentForUser.resolves({ variant: 'default' })
+    })
+
+    afterEach(function () {
+      this.getAssignmentForUser.restore()
+    })
+
+    describe('not a member of the project', function () {
+      beforeEach(function (done) {
+        this.projectName = `token-link-sharing-changes${Math.random()}`
+        this.owner.createProject(this.projectName, (err, projectId) => {
+          if (err != null) {
+            return done(err)
+          }
+          this.projectId = projectId
+          this.owner.makeTokenBased(this.projectId, err => {
+            if (err != null) {
+              return done(err)
+            }
+            this.owner.getProject(this.projectId, (err, project) => {
+              if (err != null) {
+                return done(err)
+              }
+              this.tokens = project.tokens
+              done()
+            })
+          })
+        })
+      })
+
+      it('should deny access', function (done) {
+        async.series(
+          [
+            cb => {
+              trySharingUpdatesPage(
+                this.other1,
+                this.projectId,
+                expectErrorResponse.restricted.html,
+                cb
+              )
+            },
+            cb => {
+              trySharingUpdatesJoin(
+                this.other1,
+                this.projectId,
+                expectErrorResponse.restricted.html,
+                cb
+              )
+            },
+            cb => {
+              trySharingUpdatesView(
+                this.other1,
+                this.projectId,
+                expectErrorResponse.restricted.html,
+                cb
+              )
+            },
+          ],
+          done
+        )
+      })
+    })
+
+    describe('read and write token member of project', function () {
+      beforeEach(function (done) {
+        this.projectName = `token-link-sharing-changes${Math.random()}`
+        this.owner.createProject(this.projectName, (err, projectId) => {
+          if (err != null) {
+            return done(err)
+          }
+          this.projectId = projectId
+          this.owner.makeTokenBased(this.projectId, err => {
+            if (err != null) {
+              return done(err)
+            }
+            this.owner.getProject(this.projectId, (err, project) => {
+              if (err != null) {
+                return done(err)
+              }
+              this.tokens = project.tokens
+              // must do token accept before split test enabled
+              // otherwise would be automatically added to named collaborators
+              tryReadAndWriteTokenAccept(
+                this.other1,
+                this.tokens.readAndWrite,
+                (response, body) => {
+                  expect(response.statusCode).to.equal(200)
+                },
+                (response, body) => {
+                  expect(response.statusCode).to.equal(200)
+                  expect(body.redirect).to.equal(`/project/${this.projectId}`)
+                  expect(body.tokenAccessGranted).to.equal('readAndWrite')
+                },
+                done
+              )
+            })
+          })
+        })
+      })
+
+      describe('link sharing changes test not active', function () {
+        it('should redirect to project, same permissions as before', function (done) {
+          async.series(
+            [
+              cb => {
+                trySharingUpdatesPage(
+                  this.other1,
+                  this.projectId,
+                  expectRedirectToProject,
+                  cb
+                )
+              },
+              cb => {
+                trySharingUpdatesJoin(
+                  this.other1,
+                  this.projectId,
+                  expectRedirectToProject,
+                  cb
+                )
+              },
+              cb => {
+                trySharingUpdatesView(
+                  this.other1,
+                  this.projectId,
+                  expectRedirectToProject,
+                  cb
+                )
+              },
+              cb => {
+                tryContentAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(body.privilegeLevel).to.equal('readAndWrite')
+                    expect(body.isRestrictedUser).to.equal(false)
+                    expect(body.isTokenMember).to.equal(true)
+                    expect(body.isInvitedMember).to.equal(false)
+                    expect(body.project.owner).to.have.all.keys(
+                      '_id',
+                      'email',
+                      'first_name',
+                      'last_name',
+                      'privileges',
+                      'signUpDate'
+                    )
+                  },
+                  cb
+                )
+              },
+              cb => {
+                tryEditorAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(response.statusCode).to.equal(200)
+                  },
+                  cb
+                )
+              },
+            ],
+            done
+          )
+        })
+      })
+
+      describe('link sharing changes test is active', function () {
+        beforeEach(function () {
+          this.getAssignmentForUser.resolves({ variant: 'active' })
+        })
+        it('should show sharing updates page', function (done) {
+          trySharingUpdatesPage(
+            this.other1,
+            this.projectId,
+            (response, body) => {
+              expect(response.statusCode).to.equal(200)
+            },
+            done
+          )
+        })
+
+        it('should allow join to named collaborator', function (done) {
+          async.series(
+            [
+              cb => {
+                trySharingUpdatesJoin(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(response.statusCode).to.equal(204)
+                  },
+                  cb
+                )
+              },
+              cb => {
+                tryContentAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(body.privilegeLevel).to.equal('readAndWrite')
+                    expect(body.isRestrictedUser).to.equal(false)
+                    expect(body.isTokenMember).to.equal(false)
+                    expect(body.isInvitedMember).to.equal(true) // now collaborator
+                    expect(body.project.owner).to.have.all.keys(
+                      '_id',
+                      'email',
+                      'first_name',
+                      'last_name',
+                      'privileges',
+                      'signUpDate'
+                    )
+                  },
+                  cb
+                )
+              },
+              cb => {
+                tryEditorAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(response.statusCode).to.equal(200)
+                  },
+                  cb
+                )
+              },
+            ],
+            done
+          )
+        })
+
+        it('should allow move to anonymous viewer', function (done) {
+          async.series(
+            [
+              cb => {
+                trySharingUpdatesView(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(response.statusCode).to.equal(204)
+                  },
+                  cb
+                )
+              },
+              cb => {
+                tryContentAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(body.privilegeLevel).to.equal('readOnly')
+                    expect(body.isRestrictedUser).to.equal(true)
+                    expect(body.isTokenMember).to.equal(true)
+                    expect(body.isInvitedMember).to.equal(false)
+                    expect(body.project.owner).to.have.keys('_id')
+                  },
+                  cb
+                )
+              },
+              cb => {
+                tryEditorAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(response.statusCode).to.equal(200)
+                  },
+                  cb
+                )
+              },
+            ],
+            done
+          )
+        })
+      })
+    })
+
+    describe('read-only token member of project', function () {
+      beforeEach(function (done) {
+        this.projectName = `token-link-sharing-changes${Math.random()}`
+        this.owner.createProject(this.projectName, (err, projectId) => {
+          if (err != null) {
+            return done(err)
+          }
+          this.projectId = projectId
+          this.owner.makeTokenBased(this.projectId, err => {
+            if (err != null) {
+              return done(err)
+            }
+            this.owner.getProject(this.projectId, (err, project) => {
+              if (err != null) {
+                return done(err)
+              }
+              this.tokens = project.tokens
+              tryReadOnlyTokenAccept(
+                this.other1,
+                this.tokens.readOnly,
+                (response, body) => {
+                  expect(response.statusCode).to.equal(200)
+                },
+                (response, body) => {
+                  expect(response.statusCode).to.equal(200)
+                  expect(body.redirect).to.equal(`/project/${this.projectId}`)
+                  expect(body.tokenAccessGranted).to.equal('readOnly')
+                },
+                done
+              )
+            })
+          })
+        })
+      })
+
+      describe('link sharing changes test is active', function () {
+        beforeEach(function () {
+          this.getAssignmentForUser.resolves({ variant: 'active' })
+        })
+
+        it('should redirect to project, same view permissions as before', function (done) {
+          async.series(
+            [
+              cb => {
+                trySharingUpdatesPage(
+                  this.other1,
+                  this.projectId,
+                  expectRedirectToProject,
+                  cb
+                )
+              },
+              cb => {
+                trySharingUpdatesJoin(
+                  this.other1,
+                  this.projectId,
+                  expectRedirectToProject,
+                  cb
+                )
+              },
+              cb => {
+                trySharingUpdatesView(
+                  this.other1,
+                  this.projectId,
+                  expectRedirectToProject,
+                  cb
+                )
+              },
+              cb => {
+                // allow content access read-only
+                tryContentAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(body.privilegeLevel).to.equal('readOnly')
+                    expect(body.isRestrictedUser).to.equal(true)
+                    expect(body.isTokenMember).to.equal(true)
+                    expect(body.isInvitedMember).to.equal(false)
+                    expect(body.project.owner).to.have.keys('_id')
+                    expect(body.project.owner).to.not.have.any.keys(
+                      'email',
+                      'first_name',
+                      'last_name'
+                    )
+                  },
+                  cb
+                )
+              },
+              cb => {
+                tryEditorAccess(
+                  this.other1,
+                  this.projectId,
+                  (response, body) => {
+                    expect(response.statusCode).to.equal(200)
+                  },
+                  cb
+                )
+              },
+            ],
+            done
+          )
+        })
+      })
+    })
+  })
 })

+ 54 - 0
services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js

@@ -231,6 +231,38 @@ describe('CollaboratorsGetter', function () {
     })
   })
 
+  describe('isUserInvitedReadWriteMemberOfProject', function () {
+    describe('when user is a read write member of the project', function () {
+      it('should return true', async function () {
+        const isMember =
+          await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+            this.readWriteRef1
+          )
+        expect(isMember).to.equal(true)
+      })
+    })
+
+    describe('when user is a read only member of the project', function () {
+      it('should return false', async function () {
+        const isMember =
+          await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+            this.readOnlyRef1
+          )
+        expect(isMember).to.equal(false)
+      })
+    })
+
+    describe('when user is not a member of the project', function () {
+      it('should return false', async function () {
+        const isMember =
+          await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+            this.nonMemberRef
+          )
+        expect(isMember).to.equal(false)
+      })
+    })
+  })
+
   describe('getProjectsUserIsMemberOf', function () {
     beforeEach(function () {
       this.fields = 'mock fields'
@@ -365,6 +397,28 @@ describe('CollaboratorsGetter', function () {
     })
   })
 
+  describe('userIsReadWriteTokenMember', function () {
+    it('should return true when the project is found', async function () {
+      this.ProjectMock.expects('findOne').chain('exec').resolves(this.project)
+      const isMember =
+        await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+          this.userId,
+          this.project._id
+        )
+      expect(isMember).to.be.true
+    })
+
+    it('should return false when the project is not found', async function () {
+      this.ProjectMock.expects('findOne').chain('exec').resolves(null)
+      const isMember =
+        await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+          this.userId,
+          this.project._id
+        )
+      expect(isMember).to.be.false
+    })
+  })
+
   describe('getPublicShareTokens', function () {
     const userMock = new ObjectId()
 

+ 49 - 0
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -127,6 +127,8 @@ describe('ProjectController', function () {
       promises: {
         userIsTokenMember: sinon.stub().resolves(false),
         isUserInvitedMemberOfProject: sinon.stub().resolves(true),
+        userIsReadWriteTokenMember: sinon.stub().resolves(false),
+        isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(true),
       },
     }
     this.ProjectEntityHandler = {}
@@ -1002,6 +1004,53 @@ describe('ProjectController', function () {
         this.ProjectController.loadEditor(this.req, this.res)
       })
     })
+
+    describe('link sharing changes active', function () {
+      beforeEach(function () {
+        this.SplitTestHandler.promises.getAssignmentForUser.resolves({
+          variant: 'active',
+        })
+      })
+
+      describe('when user is a read write token member (and not already a named editor)', function () {
+        beforeEach(function () {
+          this.CollaboratorsGetter.promises.userIsTokenMember.resolves(true)
+          this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+            true
+          )
+          this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+            false
+          )
+        })
+
+        it('should redirect to the sharing-updates page', function (done) {
+          this.res.redirect = url => {
+            expect(url).to.equal(`/project/${this.project_id}/sharing-updates`)
+            done()
+          }
+          this.ProjectController.loadEditor(this.req, this.res)
+        })
+      })
+
+      describe('when user is a read write token member but also a named editor', function () {
+        beforeEach(function () {
+          this.CollaboratorsGetter.promises.userIsTokenMember.resolves(true)
+          this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+            true
+          )
+          this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+            true
+          )
+        })
+
+        it('should not redirect to the sharing-updates page, and should load the editor', function (done) {
+          this.res.render = (pageName, opts) => {
+            done()
+          }
+          this.ProjectController.loadEditor(this.req, this.res)
+        })
+      })
+    })
   })
 
   describe('userProjectsJson', function () {

+ 223 - 0
services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js

@@ -21,6 +21,7 @@ describe('TokenAccessController', function () {
     }
     this.req = new MockRequest()
     this.res = new MockResponse()
+    this.next = sinon.stub().returns()
 
     this.Settings = {}
     this.TokenAccessHandler = {
@@ -40,6 +41,8 @@ describe('TokenAccessController', function () {
         getProjectByToken: sinon.stub().resolves(this.project),
         getV1DocPublishedInfo: sinon.stub().resolves({ allow: true }),
         getV1DocInfo: sinon.stub(),
+        removeReadAndWriteUserFromProject: sinon.stub().resolves(),
+        moveReadAndWriteUserToReadOnly: sinon.stub().resolves(),
       },
     }
 
@@ -77,11 +80,26 @@ describe('TokenAccessController', function () {
     this.CollaboratorsHandler = {
       promises: {
         addUserIdToProject: sinon.stub().resolves(),
+        setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
+      },
+    }
+
+    this.CollaboratorsGetter = {
+      promises: {
+        userIsReadWriteTokenMember: sinon.stub().resolves(),
+        isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(),
+        isUserInvitedMemberOfProject: sinon.stub().resolves(),
       },
     }
 
     this.EditorRealTimeController = { emitToRoom: sinon.stub() }
 
+    this.ProjectGetter = {
+      promises: {
+        getProject: sinon.stub().resolves(this.project),
+      },
+    }
+
     this.TokenAccessController = SandboxedModule.require(MODULE_PATH, {
       requires: {
         '@overleaf/settings': this.Settings,
@@ -96,7 +114,12 @@ describe('TokenAccessController', function () {
         '../SplitTests/SplitTestHandler': this.SplitTestHandler,
         '../Errors/Errors': (this.Errors = { NotFoundError: sinon.stub() }),
         '../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
+        '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
         '../Editor/EditorRealTimeController': this.EditorRealTimeController,
+        '../Project/ProjectGetter': this.ProjectGetter,
+        '../Helpers/AsyncFormHelper': (this.AsyncFormHelper = {
+          redirect: sinon.stub(),
+        }),
       },
     })
   })
@@ -721,4 +744,204 @@ describe('TokenAccessController', function () {
       )
     })
   })
+
+  describe('ensureUserCanUseSharingUpdatesConsentPage', function () {
+    beforeEach(function () {
+      this.req.params = { Project_id: this.project._id }
+    })
+
+    describe('when not in link sharing changes test', function () {
+      beforeEach(function (done) {
+        this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
+        this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+          this.req,
+          this.res,
+          done
+        )
+      })
+
+      it('redirects to the project/editor', function () {
+        expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
+          this.req,
+          this.res,
+          `/project/${this.project._id}`
+        )
+      })
+    })
+
+    describe('when link sharing changes test active', function () {
+      beforeEach(function () {
+        this.SplitTestHandler.promises.getAssignmentForUser.resolves({
+          variant: 'active',
+        })
+      })
+
+      describe('when user is not an invited editor and is a read write token member', function () {
+        beforeEach(function (done) {
+          this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+            false
+          )
+          this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+            true
+          )
+          this.next.callsFake(() => done())
+          this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+            this.req,
+            this.res,
+            this.next
+          )
+        })
+
+        it('calls next', function () {
+          expect(
+            this.CollaboratorsGetter.promises
+              .isUserInvitedReadWriteMemberOfProject
+          ).to.have.been.calledWith(this.user._id, this.project._id)
+          expect(
+            this.CollaboratorsGetter.promises.userIsReadWriteTokenMember
+          ).to.have.been.calledWith(this.user._id, this.project._id)
+          expect(this.next).to.have.been.calledOnce
+          expect(this.next.firstCall.args[0]).to.not.exist
+        })
+      })
+
+      describe('when user is already an invited editor', function () {
+        beforeEach(function (done) {
+          this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+            true
+          )
+          this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
+          this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+            this.req,
+            this.res,
+            done
+          )
+        })
+
+        it('redirects to the project/editor', function () {
+          expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
+            this.req,
+            this.res,
+            `/project/${this.project._id}`
+          )
+        })
+      })
+
+      describe('when user not a read write token member', function () {
+        beforeEach(function (done) {
+          this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+            false
+          )
+          this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
+          this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+            this.req,
+            this.res,
+            done
+          )
+        })
+
+        it('redirects to the project/editor', function () {
+          expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
+            this.req,
+            this.res,
+            `/project/${this.project._id}`
+          )
+        })
+      })
+    })
+  })
+
+  describe('moveReadWriteToCollaborators', function () {
+    beforeEach(function () {
+      this.req.params = { Project_id: this.project._id }
+    })
+
+    describe('read only invited viewer gaining edit access via link sharing', function () {
+      beforeEach(function (done) {
+        this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
+          true
+        )
+        this.res.callback = done
+        this.TokenAccessController.moveReadWriteToCollaborators(
+          this.req,
+          this.res,
+          done
+        )
+      })
+
+      it('sets the privilege level to read and write for the invited viewer', function () {
+        expect(
+          this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
+        ).to.have.been.calledWith(
+          this.project._id,
+          this.user._id,
+          PrivilegeLevels.READ_AND_WRITE
+        )
+        expect(this.res.sendStatus).to.have.been.calledWith(204)
+      })
+    })
+    describe('previously joined token access user moving to named collaborator', function () {
+      beforeEach(function (done) {
+        this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
+          false
+        )
+        this.res.callback = done
+        this.TokenAccessController.moveReadWriteToCollaborators(
+          this.req,
+          this.res,
+          done
+        )
+      })
+
+      it('sets the privilege level to read and write for the invited viewer', function () {
+        expect(
+          this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
+        ).to.have.been.calledWith(this.user._id, this.project._id)
+        expect(
+          this.CollaboratorsHandler.promises.addUserIdToProject
+        ).to.have.been.calledWith(
+          this.project._id,
+          undefined,
+          this.user._id,
+          PrivilegeLevels.READ_AND_WRITE
+        )
+        expect(this.res.sendStatus).to.have.been.calledWith(204)
+      })
+    })
+  })
+
+  describe('moveReadWriteToReadOnly', function () {
+    beforeEach(function () {
+      this.req.params = { Project_id: this.project._id }
+    })
+
+    describe('previously joined token access user moving to anonymous viewer', function () {
+      beforeEach(function (done) {
+        this.res.callback = done
+        this.TokenAccessController.moveReadWriteToReadOnly(
+          this.req,
+          this.res,
+          done
+        )
+      })
+
+      it('removes them from read write token access refs and adds them to read only token access refs', function () {
+        expect(
+          this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly
+        ).to.have.been.calledWith(this.user._id, this.project._id)
+        expect(this.res.sendStatus).to.have.been.calledWith(204)
+      })
+
+      it('writes a project audit log', function () {
+        expect(
+          this.ProjectAuditLogHandler.promises.addEntry
+        ).to.have.been.calledWith(
+          this.project._id,
+          'readonly-via-sharing-updates',
+          this.user._id,
+          this.req.ip
+        )
+      })
+    })
+  })
 })

+ 53 - 0
services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js

@@ -200,6 +200,59 @@ describe('TokenAccessHandler', function () {
     })
   })
 
+  describe('removeReadAndWriteUserFromProject', function () {
+    beforeEach(function () {
+      this.Project.updateOne = sinon
+        .stub()
+        .returns({ exec: sinon.stub().resolves(null) })
+    })
+
+    it('should call Project.updateOne', async function () {
+      await this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
+        this.userId,
+        this.projectId
+      )
+
+      expect(this.Project.updateOne.callCount).to.equal(1)
+      expect(
+        this.Project.updateOne.calledWith({
+          _id: this.projectId,
+        })
+      ).to.equal(true)
+      expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
+        'tokenAccessReadAndWrite_refs'
+      )
+    })
+  })
+
+  describe('moveReadAndWriteUserToReadOnly', function () {
+    beforeEach(function () {
+      this.Project.updateOne = sinon
+        .stub()
+        .returns({ exec: sinon.stub().resolves(null) })
+    })
+
+    it('should call Project.updateOne', async function () {
+      await this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
+        this.userId,
+        this.projectId
+      )
+
+      expect(this.Project.updateOne.callCount).to.equal(1)
+      expect(
+        this.Project.updateOne.calledWith({
+          _id: this.projectId,
+        })
+      ).to.equal(true)
+      expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
+        'tokenAccessReadAndWrite_refs'
+      )
+      expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
+        'tokenAccessReadOnly_refs'
+      )
+    })
+  })
+
   describe('grantSessionTokenAccess', function () {
     beforeEach(function () {
       this.req = { session: {}, headers: {} }