Przeglądaj źródła

Merge pull request #31827 from overleaf/ii-project-sharing-access-denied

[web] Project sharing access denied redesign

GitOrigin-RevId: b1e3016eb7ef9e2a502e0b67abc3b10c08531fe9
ilkin-overleaf 4 miesięcy temu
rodzic
commit
d61413e57d

+ 9 - 4
services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs

@@ -263,10 +263,18 @@ async function viewInvite(req, res) {
   const projectId = req.params.Project_id
   const { token } = req.params
 
+  const { variant: sharingUpdates } =
+    await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates')
+
   const _renderInvalidPage = function () {
     res.status(404)
     logger.debug({ projectId }, 'invite not valid, rendering not-valid page')
-    res.render('project/invite/not-valid', { title: 'Invalid Invite' })
+
+    if (sharingUpdates === 'enabled') {
+      res.render('project/invite/not-valid', { title: 'Invalid Invite' })
+    } else {
+      res.render('project/invite/not-valid-legacy', { title: 'Invalid Invite' })
+    }
   }
 
   // check if the user is already a member of the project
@@ -329,9 +337,6 @@ async function viewInvite(req, res) {
   // cleanup if set for register page
   delete req.session.sharedProjectData
 
-  const { variant: sharingUpdates } =
-    await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates')
-
   // finally render the invite
   if (sharingUpdates === 'enabled') {
     res.render('project/invite/show', {

+ 18 - 0
services/web/app/views/project/invite/not-valid-legacy.pug

@@ -0,0 +1,18 @@
+extends ../../layout-marketing
+
+block content
+	main#main-content.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2.offset-md-2
+					.card.project-invite-invalid
+						.card-body
+							.page-header.text-center
+								h1 #{translate("invite_not_valid")}
+							.row.text-center
+								.col-12.col-md-12
+									p
+										| #{translate("invite_not_valid_description")}.
+							.row.text-center.actions
+								.col-12.col-md-12
+									a.btn.btn-secondary-info.btn-secondary(href='/project') #{translate("back_to_your_projects")}

+ 11 - 15
services/web/app/views/project/invite/not-valid.pug

@@ -1,18 +1,14 @@
-extends ../../layout-marketing
+extends ../../layout-website-redesign
+
+block vars
+	- isWebsiteRedesign = true
+
+block entrypointVar
+	- entrypoint = 'pages/project-invite-not-valid'
+
+block append meta
+	meta(name='ol-user' data-type='json' content=user)
 
 block content
 	main#main-content.content.content-alt
-		.container
-			.row
-				.col-md-8.col-md-offset-2.offset-md-2
-					.card.project-invite-invalid
-						.card-body
-							.page-header.text-center
-								h1 #{translate("invite_not_valid")}
-							.row.text-center
-								.col-12.col-md-12
-									p
-										| #{translate("invite_not_valid_description")}.
-							.row.text-center.actions
-								.col-12.col-md-12
-									a.btn.btn-secondary-info.btn-secondary(href='/project') #{translate("back_to_your_projects")}
+		#project-invite-not-valid-page

+ 1 - 1
services/web/app/views/project/token/access-react-legacy.pug

@@ -13,4 +13,4 @@ block append meta
 	meta(name='ol-user' data-type='json' content=user)
 
 block content
-	#token-access-page
+	#token-access-page.token-access-legacy-page

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

@@ -188,6 +188,7 @@
   "back_to_configuration": "",
   "back_to_editing": "",
   "back_to_editor": "",
+  "back_to_my_projects": "",
   "back_to_subscription": "",
   "back_to_your_projects": "",
   "basic_compile_time": "",
@@ -1725,6 +1726,7 @@
   "sorry_there_are_no_experiments": "",
   "sorry_there_was_an_issue_adding_x_users_to_your_subscription": "",
   "sorry_there_was_an_issue_upgrading_your_subscription": "",
+  "sorry_this_project_is_not_available": "",
   "sorry_you_can_only_change_to_group_from_trial_via_support": "",
   "sorry_you_can_only_change_to_group_via_support": "",
   "sorry_your_table_cant_be_displayed_at_the_moment": "",
@@ -1901,6 +1903,7 @@
   "the_following_folder_already_exists_in_this_project_plural": "",
   "the_home_of_research_writing": "",
   "the_latex_engine_used_for_compiling": "",
+  "the_link_may_be_broken_or_you_may_not_have_access_rights": "",
   "the_new_and_improved_overleaf_editor_design": "",
   "the_next_payment_will_be_collected_on": "",
   "the_original_text_has_changed": "",
@@ -2282,6 +2285,7 @@
   "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "",
   "you_are_currently_logged_in_as": "",
+  "you_are_currently_logged_in_as_x_you_might_need_to_log_in_with_different_email": "",
   "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": "",

+ 15 - 0
services/web/frontend/js/features/share-project/invite-not-valid-root.tsx

@@ -0,0 +1,15 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import getMeta from '@/utils/meta'
+import InviteNotValid from '@/features/share-project/invite-not-valid'
+import { User } from '@ol-types/user'
+
+export default function InviteNotValidRoot() {
+  const user = getMeta('ol-user') as User | undefined
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+
+  return <InviteNotValid email={user?.email} />
+}

+ 58 - 0
services/web/frontend/js/features/share-project/invite-not-valid.tsx

@@ -0,0 +1,58 @@
+import { useTranslation, Trans } from 'react-i18next'
+import OLRow from '@/shared/components/ol/ol-row'
+import OLCol from '@/shared/components/ol/ol-col'
+import OLButton from '@/shared/components/ol/ol-button'
+import overleafLogo from '@/shared/svgs/overleaf-logo.svg'
+import getMeta from '@/utils/meta'
+
+type InviteNotValidProps = {
+  email?: string
+}
+
+function InviteNotValid({ email }: InviteNotValidProps) {
+  const { t } = useTranslation()
+  const { appName } = getMeta('ol-ExposedSettings')
+
+  return (
+    <div className="container">
+      <OLRow>
+        <OLCol lg={{ span: 6, offset: 3 }}>
+          <div className="project-join-container">
+            <img src={overleafLogo} alt={appName} />
+            <h1 className="h4 mb-2">
+              {t('sorry_this_project_is_not_available')}
+            </h1>
+            <div className="mb-4">
+              {t('the_link_may_be_broken_or_you_may_not_have_access_rights')}
+            </div>
+            {email && (
+              <>
+                <OLButton
+                  variant="primary"
+                  size="lg"
+                  href="/project"
+                  className="mb-4"
+                >
+                  {t('back_to_my_projects')}
+                </OLButton>
+                <div>
+                  <small>
+                    <Trans
+                      i18nKey="you_are_currently_logged_in_as_x_you_might_need_to_log_in_with_different_email"
+                      components={[<b />]} // eslint-disable-line react/jsx-key
+                      values={{ email }}
+                      shouldUnescape
+                      tOptions={{ interpolation: { escapeValue: true } }}
+                    />
+                  </small>
+                </div>
+              </>
+            )}
+          </div>
+        </OLCol>
+      </OLRow>
+    </div>
+  )
+}
+
+export default InviteNotValid

+ 29 - 0
services/web/frontend/js/features/token-access/components/access-attempt-screen.tsx

@@ -1,5 +1,8 @@
 import { FC } from 'react'
 import { useTranslation } from 'react-i18next'
+import InviteNotValid from '@/features/share-project/invite-not-valid'
+import getMeta from '@/utils/meta'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 export const AccessAttemptScreen: FC<{
   loadingScreenBrandHeight: string
@@ -7,6 +10,32 @@ export const AccessAttemptScreen: FC<{
   accessError: string | boolean
 }> = ({ loadingScreenBrandHeight, inflight, accessError }) => {
   const { t } = useTranslation()
+  const user = getMeta('ol-user')
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
+
+  if (isSharingUpdatesEnabled) {
+    if (accessError) {
+      return <InviteNotValid email={user?.email} />
+    }
+
+    return (
+      <div className="vertically-centered-content">
+        <div className="loading-screen">
+          <div className="loading-screen-brand-container">
+            <div
+              className="loading-screen-brand"
+              style={{ height: loadingScreenBrandHeight }}
+            />
+          </div>
+
+          <h3 className="loading-screen-label text-center">
+            {t('join_project')}
+            {inflight && <LoadingScreenEllipses />}
+          </h3>
+        </div>
+      </div>
+    )
+  }
 
   return (
     <div className="loading-screen">

+ 12 - 0
services/web/frontend/js/pages/project-invite-not-valid.tsx

@@ -0,0 +1,12 @@
+import './../utils/meta'
+import '../utils/webpack-public-path'
+import './../infrastructure/error-reporter'
+import '@/i18n'
+import { createRoot } from 'react-dom/client'
+import InviteNotValidRoot from '@/features/share-project/invite-not-valid-root'
+
+const element = document.getElementById('project-invite-not-valid-page')
+if (element) {
+  const root = createRoot(element)
+  root.render(<InviteNotValidRoot />)
+}

+ 8 - 2
services/web/frontend/stylesheets/pages/token-access.scss

@@ -1,6 +1,12 @@
 #token-access-page {
-  height: 100vh;
-  height: 100dvh;
+  &.token-access-legacy-page {
+    height: 100vh;
+    height: 100dvh;
+  }
+
+  .vertically-centered-content {
+    height: $thin-footer-content-height;
+  }
 }
 
 @include theme('default') {

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

@@ -236,6 +236,7 @@
   "back_to_editing": "Back to editing",
   "back_to_editor": "Back to editor",
   "back_to_log_in": "Back to log in",
+  "back_to_my_projects": "Back to my projects",
   "back_to_subscription": "Back to subscription",
   "back_to_your_projects": "Back to your projects",
   "basic": "Basic",
@@ -2211,6 +2212,7 @@
   "sorry_there_was_an_issue_adding_x_users_to_your_subscription": "Sorry, there was an issue adding __count__ users to your subscription. Please <0>contact our Support team</0> for help.",
   "sorry_there_was_an_issue_upgrading_your_subscription": "Sorry, there was an issue upgrading your subscription. Please <0>contact our Support team</0> for help.",
   "sorry_this_account_has_been_suspended": "Sorry, this account has been suspended.",
+  "sorry_this_project_is_not_available": "Sorry, this project isn’t available",
   "sorry_you_can_only_change_to_group_from_trial_via_support": "Sorry, you can only change to a group plan during a free trial by contacting support.",
   "sorry_you_can_only_change_to_group_via_support": "Sorry, you can only change to a group plan by contacting support.",
   "sorry_your_table_cant_be_displayed_at_the_moment": "Sorry, your table can’t be displayed at the moment.",
@@ -2426,6 +2428,7 @@
   "the_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:",
   "the_home_of_research_writing": "The home of research writing.",
   "the_latex_engine_used_for_compiling": "The LaTeX engine used for compiling",
+  "the_link_may_be_broken_or_you_may_not_have_access_rights": "The link may be broken or you may not have access rights.",
   "the_new_and_improved_overleaf_editor_design": "The new and improved __appName__ editor design brings you a cleaner, less cluttered interface to help you focus on what matters—your work.",
   "the_next_payment_will_be_collected_on": "The next payment will be collected on <strong>__date__</strong>.",
   "the_original_text_has_changed": "The original text has changed, so this suggestion can’t be applied",
@@ -2851,6 +2854,7 @@
   "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are a <1>manager</1> of the <0>__planName__</0> group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>.",
   "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "You are a <1>manager</1> of the <0>__planName__</0> group subscription <1>__groupName__</1> administered by <1>you (__adminEmail__</1>).",
   "you_are_currently_logged_in_as": "You are currently logged in as <b>__email__</b>.",
+  "you_are_currently_logged_in_as_x_you_might_need_to_log_in_with_different_email": "You are currently logged in as <0>__email__</0>. You might need to log in with a different email address.",
   "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>",

+ 9 - 3
services/web/test/frontend/components/token-access/token-access-page.spec.tsx

@@ -79,10 +79,16 @@ describe('<TokenAccessPage/>', function () {
 
     cy.wait('@grantRequest')
 
-    cy.get('h3').should('have.text', 'Join Project')
-    cy.get('h4').should('have.text', 'Project not found')
-
+    cy.findByRole('heading', { name: /sorry, this project isn’t available/i })
+    cy.findByText(/the link may be broken or you may not have access rights/i)
     cy.findByRole('button', { name: 'Join Project' }).should('not.exist')
+    cy.contains(
+      new RegExp(
+        'you are currently logged in as test@example.com. ' +
+          'you might need to log in with a different email address',
+        'i'
+      )
+    )
   })
 
   it('handles a redirect response', function () {

+ 52 - 0
services/web/test/frontend/features/share-project/components/invite-not-valid.spec.tsx

@@ -0,0 +1,52 @@
+import InviteNotValid from '@/features/share-project/invite-not-valid'
+
+describe('<InviteNotValid />', function () {
+  const email = 'test@example.com'
+
+  it('renders the sorry message', function () {
+    cy.mount(<InviteNotValid email={email} />)
+
+    cy.findByRole('heading', {
+      name: /sorry, this project isn’t available/i,
+    })
+  })
+
+  it('renders the broken link message', function () {
+    cy.mount(<InviteNotValid email={email} />)
+
+    cy.findByText(/the link may be broken or you may not have access rights/i)
+  })
+
+  it('renders a back to projects button linking to /project', function () {
+    cy.mount(<InviteNotValid email={email} />)
+
+    cy.findByRole('link', { name: /back to my projects/i }).should(
+      'have.attr',
+      'href',
+      '/project'
+    )
+  })
+
+  it('renders the logged-in email', function () {
+    cy.mount(<InviteNotValid email={email} />)
+
+    cy.contains(
+      new RegExp(
+        `you are currently logged in as ${email}. you might need to log in with a different email address`,
+        'i'
+      )
+    )
+  })
+
+  it('does not render the CTA and email when email not provided', function () {
+    cy.mount(<InviteNotValid />)
+
+    cy.findByRole('link', { name: /back to my projects/i }).should('not.exist')
+    cy.contains(
+      new RegExp(
+        `you are currently logged in as ${email}. you might need to log in with a different email address`,
+        'i'
+      )
+    ).should('not.exist')
+  })
+})

+ 264 - 114
services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs

@@ -1035,54 +1035,108 @@ describe('CollaboratorsInviteController', function () {
     })
 
     describe('when the getInviteByToken does not produce an invite', function () {
-      beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null)
-          ctx.res.callback = () => resolve()
-          ctx.CollaboratorsInviteController.viewInvite(
-            ctx.req,
-            ctx.res,
-            ctx.next
+      describe('when the sharing-updates variant is "enabled"', function () {
+        beforeEach(async function (ctx) {
+          ctx.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'enabled',
+          })
+          await new Promise(resolve => {
+            ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
+              null
+            )
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
+
+        it('should render the not-valid view template', function (ctx) {
+          expect(ctx.res.render).toHaveBeenCalledTimes(1)
+          expect(ctx.res.render).toHaveBeenCalledWith(
+            'project/invite/not-valid',
+            expect.anything()
           )
         })
-      })
 
-      it('should render the not-valid view template', function (ctx) {
-        expect(ctx.res.render).toHaveBeenCalledTimes(1)
-        expect(ctx.res.render).toHaveBeenCalledWith(
-          'project/invite/not-valid',
-          expect.anything()
-        )
+        it('should not call next', function (ctx) {
+          ctx.next.callCount.should.equal(0)
+        })
       })
 
-      it('should not call next', function (ctx) {
-        ctx.next.callCount.should.equal(0)
-      })
+      describe('when the sharing-updates variant is "default"', function () {
+        beforeEach(async function (ctx) {
+          ctx.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'default',
+          })
+          await new Promise(resolve => {
+            ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
+              null
+            )
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
 
-      it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) {
-        ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal(
-          1
-        )
-        ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
-          .calledWith(ctx.currentUser._id, ctx.projectId)
-          .should.equal(true)
-      })
+        it('should render the not-valid-legacy view template', function (ctx) {
+          expect(ctx.res.render).toHaveBeenCalledTimes(1)
+          expect(ctx.res.render).toHaveBeenCalledWith(
+            'project/invite/not-valid-legacy',
+            expect.anything()
+          )
+        })
 
-      it('should call getInviteByToken', function (ctx) {
-        ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal(
-          1
-        )
-        ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
-          .calledWith(ctx.currentUser._id, ctx.projectId)
-          .should.equal(true)
+        it('should not call next', function (ctx) {
+          ctx.next.callCount.should.equal(0)
+        })
       })
 
-      it('should not call User.getUser', function (ctx) {
-        ctx.UserGetter.promises.getUser.callCount.should.equal(0)
-      })
+      describe('common behaviour', function () {
+        beforeEach(async function (ctx) {
+          await new Promise(resolve => {
+            ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
+              null
+            )
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
 
-      it('should not call ProjectGetter.getProject', function (ctx) {
-        ctx.ProjectGetter.promises.getProject.callCount.should.equal(0)
+        it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) {
+          ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal(
+            1
+          )
+          ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
+            .calledWith(ctx.currentUser._id, ctx.projectId)
+            .should.equal(true)
+        })
+
+        it('should call getInviteByToken', function (ctx) {
+          ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal(
+            1
+          )
+          ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
+            .calledWith(ctx.currentUser._id, ctx.projectId)
+            .should.equal(true)
+        })
+
+        it('should not call User.getUser', function (ctx) {
+          ctx.UserGetter.promises.getUser.callCount.should.equal(0)
+        })
+
+        it('should not call ProjectGetter.getProject', function (ctx) {
+          ctx.ProjectGetter.promises.getProject.callCount.should.equal(0)
+        })
       })
     })
 
@@ -1132,54 +1186,102 @@ describe('CollaboratorsInviteController', function () {
     })
 
     describe('when User.getUser does not find a user', function () {
-      beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.UserGetter.promises.getUser.resolves(null)
-          ctx.res.callback = () => resolve()
-          ctx.CollaboratorsInviteController.viewInvite(
-            ctx.req,
-            ctx.res,
-            ctx.next
+      describe('when the sharing-updates variant is "enabled"', function () {
+        beforeEach(async function (ctx) {
+          ctx.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'enabled',
+          })
+          await new Promise(resolve => {
+            ctx.UserGetter.promises.getUser.resolves(null)
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
+
+        it('should render the not-valid view template', function (ctx) {
+          expect(ctx.res.render).toHaveBeenCalledTimes(1)
+          expect(ctx.res.render).toHaveBeenCalledWith(
+            'project/invite/not-valid',
+            expect.anything()
           )
         })
-      })
 
-      it('should render the not-valid view template', function (ctx) {
-        expect(ctx.res.render).toHaveBeenCalledTimes(1)
-        expect(ctx.res.render).toHaveBeenCalledWith(
-          'project/invite/not-valid',
-          expect.anything()
-        )
+        it('should not call next', function (ctx) {
+          ctx.next.callCount.should.equal(0)
+        })
       })
 
-      it('should not call next', function (ctx) {
-        ctx.next.callCount.should.equal(0)
-      })
+      describe('when the sharing-updates variant is "default"', function () {
+        beforeEach(async function (ctx) {
+          ctx.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'default',
+          })
+          await new Promise(resolve => {
+            ctx.UserGetter.promises.getUser.resolves(null)
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
 
-      it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) {
-        ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal(
-          1
-        )
-        ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
-          .calledWith(ctx.currentUser._id, ctx.projectId)
-          .should.equal(true)
-      })
+        it('should render the not-valid-legacy view template', function (ctx) {
+          expect(ctx.res.render).toHaveBeenCalledTimes(1)
+          expect(ctx.res.render).toHaveBeenCalledWith(
+            'project/invite/not-valid-legacy',
+            expect.anything()
+          )
+        })
 
-      it('should call getInviteByToken', function (ctx) {
-        ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal(
-          1
-        )
+        it('should not call next', function (ctx) {
+          ctx.next.callCount.should.equal(0)
+        })
       })
 
-      it('should call User.getUser', function (ctx) {
-        ctx.UserGetter.promises.getUser.callCount.should.equal(1)
-        ctx.UserGetter.promises.getUser
-          .calledWith({ _id: ctx.fakeProject.owner_ref })
-          .should.equal(true)
-      })
+      describe('common behaviour', function () {
+        beforeEach(async function (ctx) {
+          await new Promise(resolve => {
+            ctx.UserGetter.promises.getUser.resolves(null)
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
 
-      it('should not call ProjectGetter.getProject', function (ctx) {
-        ctx.ProjectGetter.promises.getProject.callCount.should.equal(0)
+        it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) {
+          ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal(
+            1
+          )
+          ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
+            .calledWith(ctx.currentUser._id, ctx.projectId)
+            .should.equal(true)
+        })
+
+        it('should call getInviteByToken', function (ctx) {
+          ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal(
+            1
+          )
+        })
+
+        it('should call User.getUser', function (ctx) {
+          ctx.UserGetter.promises.getUser.callCount.should.equal(1)
+          ctx.UserGetter.promises.getUser
+            .calledWith({ _id: ctx.fakeProject.owner_ref })
+            .should.equal(true)
+        })
+
+        it('should not call ProjectGetter.getProject', function (ctx) {
+          ctx.ProjectGetter.promises.getProject.callCount.should.equal(0)
+        })
       })
     })
 
@@ -1229,54 +1331,102 @@ describe('CollaboratorsInviteController', function () {
     })
 
     describe('when Project.getUser does not find a user', function () {
-      beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.ProjectGetter.promises.getProject.resolves(null)
-          ctx.res.callback = () => resolve()
-          ctx.CollaboratorsInviteController.viewInvite(
-            ctx.req,
-            ctx.res,
-            ctx.next
+      describe('when the sharing-updates variant is "enabled"', function () {
+        beforeEach(async function (ctx) {
+          ctx.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'enabled',
+          })
+          await new Promise(resolve => {
+            ctx.ProjectGetter.promises.getProject.resolves(null)
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
+
+        it('should render the not-valid view template', function (ctx) {
+          expect(ctx.res.render).toHaveBeenCalledTimes(1)
+          expect(ctx.res.render).toHaveBeenCalledWith(
+            'project/invite/not-valid',
+            expect.anything()
           )
         })
-      })
 
-      it('should render the not-valid view template', function (ctx) {
-        expect(ctx.res.render).toHaveBeenCalledTimes(1)
-        expect(ctx.res.render).toHaveBeenCalledWith(
-          'project/invite/not-valid',
-          expect.anything()
-        )
+        it('should not call next', function (ctx) {
+          ctx.next.callCount.should.equal(0)
+        })
       })
 
-      it('should not call next', function (ctx) {
-        ctx.next.callCount.should.equal(0)
-      })
+      describe('when the sharing-updates variant is "default"', function () {
+        beforeEach(async function (ctx) {
+          ctx.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'default',
+          })
+          await new Promise(resolve => {
+            ctx.ProjectGetter.promises.getProject.resolves(null)
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
 
-      it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) {
-        ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal(
-          1
-        )
-        ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
-          .calledWith(ctx.currentUser._id, ctx.projectId)
-          .should.equal(true)
-      })
+        it('should render the not-valid-legacy view template', function (ctx) {
+          expect(ctx.res.render).toHaveBeenCalledTimes(1)
+          expect(ctx.res.render).toHaveBeenCalledWith(
+            'project/invite/not-valid-legacy',
+            expect.anything()
+          )
+        })
 
-      it('should call getInviteByToken', function (ctx) {
-        ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal(
-          1
-        )
+        it('should not call next', function (ctx) {
+          ctx.next.callCount.should.equal(0)
+        })
       })
 
-      it('should call getUser', function (ctx) {
-        ctx.UserGetter.promises.getUser.callCount.should.equal(1)
-        ctx.UserGetter.promises.getUser
-          .calledWith({ _id: ctx.fakeProject.owner_ref })
-          .should.equal(true)
-      })
+      describe('common behaviour', function () {
+        beforeEach(async function (ctx) {
+          await new Promise(resolve => {
+            ctx.ProjectGetter.promises.getProject.resolves(null)
+            ctx.res.callback = () => resolve()
+            ctx.CollaboratorsInviteController.viewInvite(
+              ctx.req,
+              ctx.res,
+              ctx.next
+            )
+          })
+        })
 
-      it('should call ProjectGetter.getProject', function (ctx) {
-        ctx.ProjectGetter.promises.getProject.callCount.should.equal(1)
+        it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) {
+          ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal(
+            1
+          )
+          ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject
+            .calledWith(ctx.currentUser._id, ctx.projectId)
+            .should.equal(true)
+        })
+
+        it('should call getInviteByToken', function (ctx) {
+          ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal(
+            1
+          )
+        })
+
+        it('should call getUser', function (ctx) {
+          ctx.UserGetter.promises.getUser.callCount.should.equal(1)
+          ctx.UserGetter.promises.getUser
+            .calledWith({ _id: ctx.fakeProject.owner_ref })
+            .should.equal(true)
+        })
+
+        it('should call ProjectGetter.getProject', function (ctx) {
+          ctx.ProjectGetter.promises.getProject.callCount.should.equal(1)
+        })
       })
     })
   })