Эх сурвалжийг харах

Merge pull request #18170 from overleaf/ae-token-access-page

Convert token access page to React

GitOrigin-RevId: d7434f0de395c47a95d00767727fbe9d43f9abca
Alf Eaton 2 жил өмнө
parent
commit
9729befe59
25 өөрчлөгдсөн 568 нэмэгдсэн , 2 устгасан
  1. 13 1
      services/web/app/src/Features/TokenAccess/TokenAccessController.js
  2. 16 0
      services/web/app/views/project/token/access-react.pug
  3. 4 0
      services/web/frontend/extracted-translations.json
  4. 55 0
      services/web/frontend/js/features/token-access/components/access-attempt-screen.tsx
  5. 57 0
      services/web/frontend/js/features/token-access/components/require-accept-screen.tsx
  6. 124 0
      services/web/frontend/js/features/token-access/components/token-access-root.tsx
  7. 84 0
      services/web/frontend/js/features/token-access/components/v1-import-data-screen.tsx
  8. 13 0
      services/web/frontend/js/pages/token-access.tsx
  9. 4 0
      services/web/frontend/js/shared/components/location.js
  10. 10 1
      services/web/frontend/js/shared/hooks/use-location.ts
  11. 168 0
      services/web/test/frontend/components/token-access/token-access-page.spec.tsx
  12. 1 0
      services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx
  13. 1 0
      services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
  14. 1 0
      services/web/test/frontend/features/project-list/components/notifications.test.tsx
  15. 1 0
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  16. 1 0
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
  17. 1 0
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
  18. 1 0
      services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
  19. 1 0
      services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
  20. 1 0
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx
  21. 1 0
      services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
  22. 1 0
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  23. 1 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
  24. 1 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
  25. 7 0
      services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js

+ 13 - 1
services/web/app/src/Features/TokenAccess/TokenAccessController.js

@@ -12,6 +12,7 @@ const {
   handleAdminDomainRedirect,
 } = require('../Authorization/AuthorizationMiddleware')
 const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
+const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 
 const orderedPrivilegeLevels = [
   PrivilegeLevels.NONE,
@@ -97,7 +98,18 @@ async function tokenAccessPage(req, res, next) {
       }
     }
 
-    res.render('project/token/access', {
+    const { variant } = await SplitTestHandler.promises.getAssignment(
+      req,
+      res,
+      'token-access-page'
+    )
+
+    const view =
+      variant === 'react'
+        ? 'project/token/access-react'
+        : 'project/token/access'
+
+    res.render(view, {
       postUrl: makePostUrl(token),
     })
   } catch (err) {

+ 16 - 0
services/web/app/views/project/token/access-react.pug

@@ -0,0 +1,16 @@
+extends ../../layout-marketing
+
+block entrypointVar
+	- entrypoint = 'pages/token-access'
+
+block vars
+	- var suppressFooter = true
+	- var suppressCookieBanner = true
+	- var suppressSkipToContent = true
+		
+block append meta
+	meta(name="ol-postUrl" data-type="string" content=postUrl)
+	meta(name="ol-user" data-type="json" content=user)
+
+block content
+	div#token-access-page

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

@@ -22,6 +22,7 @@
   "accept_or_reject_each_changes_individually": "",
   "accept_terms_and_conditions": "",
   "accepted_invite": "",
+  "accepting_invite_as": "",
   "access_denied": "",
   "account_has_been_link_to_institution_account": "",
   "account_has_past_due_invoice_change_plan_warning": "",
@@ -532,6 +533,7 @@
   "history_view_all": "",
   "history_view_labels": "",
   "hit_enter_to_reply": "",
+  "home": "",
   "hotkey_add_a_comment": "",
   "hotkey_autocomplete_menu": "",
   "hotkey_beginning_of_document": "",
@@ -619,6 +621,7 @@
   "invite_not_accepted": "",
   "invited_to_group": "",
   "invited_to_group_have_individual_subcription": "",
+  "invited_to_join": "",
   "ip_address": "",
   "is_email_affiliated": "",
   "issued_on": "",
@@ -1335,6 +1338,7 @@
   "to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package": "",
   "toggle_compile_options_menu": "",
   "token": "",
+  "token_access_failure": "",
   "token_limit_reached": "",
   "token_read_only": "",
   "token_read_write": "",

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

@@ -0,0 +1,55 @@
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+
+export const AccessAttemptScreen: FC<{
+  loadingScreenBrandHeight: string
+  inflight: boolean
+  accessError: string | boolean
+}> = ({ loadingScreenBrandHeight, inflight, accessError }) => {
+  const { t } = useTranslation()
+
+  return (
+    <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>
+
+      {accessError && (
+        <div className="global-alerts text-center">
+          <div>
+            <br />
+            {accessError === 'not_found' ? (
+              <div>
+                <h4 aria-live="assertive">Project not found</h4>
+              </div>
+            ) : (
+              <div>
+                <div className="alert alert-danger" aria-live="assertive">
+                  {t('token_access_failure')}
+                </div>
+                <p>
+                  <a href="/">{t('home')}</a>
+                </p>
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+const LoadingScreenEllipses = () => (
+  <span aria-hidden>
+    <span className="loading-screen-ellip">.</span>
+    <span className="loading-screen-ellip">.</span>
+    <span className="loading-screen-ellip">.</span>
+  </span>
+)

+ 57 - 0
services/web/frontend/js/features/token-access/components/require-accept-screen.tsx

@@ -0,0 +1,57 @@
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import getMeta from '@/utils/meta'
+
+export type RequireAcceptData = {
+  projectName?: string
+}
+
+export const RequireAcceptScreen: FC<{
+  requireAcceptData: RequireAcceptData
+  sendPostRequest: (confirmedByUser: boolean) => void
+}> = ({ requireAcceptData, sendPostRequest }) => {
+  const { t } = useTranslation()
+  const user = getMeta('ol-user')
+
+  return (
+    <div className="loading-screen">
+      <div className="container">
+        <div className="row">
+          <div className="col-md-8 col-md-offset-2">
+            <div className="card">
+              <div className="page-header text-centered">
+                <h1>
+                  {t('invited_to_join')}
+                  <br />
+                  <em>{requireAcceptData.projectName || 'This project'}</em>
+                </h1>
+              </div>
+
+              {user && (
+                <div className="row text-center">
+                  <div className="col-md-12">
+                    <p>
+                      {t('accepting_invite_as')} <em>{user.email}</em>
+                    </p>
+                  </div>
+                </div>
+              )}
+
+              <div className="row text-center">
+                <div className="col-md-12">
+                  <button
+                    className="btn btn-lg btn-primary"
+                    type="submit"
+                    onClick={() => sendPostRequest(true)}
+                  >
+                    {t('join_project')}
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 124 - 0
services/web/frontend/js/features/token-access/components/token-access-root.tsx

@@ -0,0 +1,124 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import getMeta from '@/utils/meta'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import { useLocation } from '@/shared/hooks/use-location'
+import {
+  V1ImportData,
+  V1ImportDataScreen,
+} from '@/features/token-access/components/v1-import-data-screen'
+import { AccessAttemptScreen } from '@/features/token-access/components/access-attempt-screen'
+import {
+  RequireAcceptData,
+  RequireAcceptScreen,
+} from '@/features/token-access/components/require-accept-screen'
+import Icon from '@/shared/components/icon'
+
+type Mode = 'access-attempt' | 'v1Import' | 'requireAccept'
+
+function TokenAccessRoot() {
+  const [mode, setMode] = useState<Mode>('access-attempt')
+  const [inflight, setInflight] = useState(false)
+  const [accessError, setAccessError] = useState<string | boolean>(false)
+  const [v1ImportData, setV1ImportData] = useState<V1ImportData>()
+  const [requireAcceptData, setRequireAcceptData] =
+    useState<RequireAcceptData>()
+  const [loadingScreenBrandHeight, setLoadingScreenBrandHeight] =
+    useState('0px')
+  const location = useLocation()
+
+  const sendPostRequest = useCallback(
+    (confirmedByUser = false) => {
+      setInflight(true)
+
+      postJSON(getMeta('ol-postUrl'), {
+        body: {
+          confirmedByUser,
+          tokenHashPrefix: document.location.hash,
+        },
+      })
+        .then(async data => {
+          setAccessError(false)
+
+          if (data.redirect) {
+            location.replace(data.redirect)
+          } else if (data.v1Import) {
+            setMode('v1Import')
+            setV1ImportData(data.v1Import)
+          } else if (data.requireAccept) {
+            setMode('requireAccept')
+            setRequireAcceptData(data.requireAccept)
+          } else {
+            debugConsole.warn(
+              'invalid data from server in success response',
+              data
+            )
+            setAccessError(true)
+          }
+        })
+        .catch(error => {
+          debugConsole.warn('error response from server', error)
+          setAccessError(error.response?.status === 404 ? 'not_found' : 'error')
+        })
+        .finally(() => {
+          setInflight(false)
+        })
+    },
+    [location]
+  )
+
+  const postedRef = useRef(false)
+  useEffect(() => {
+    if (!postedRef.current) {
+      postedRef.current = true
+      sendPostRequest()
+      setTimeout(() => {
+        setLoadingScreenBrandHeight('20%')
+      }, 500)
+    }
+  }, [sendPostRequest])
+
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+
+  return (
+    <div className="full-size">
+      <div>
+        <a
+          href="/project"
+          // TODO: class name
+          style={{ fontSize: '2rem', marginLeft: '1rem', color: '#ddd' }}
+        >
+          <Icon type="arrow-left" />
+        </a>
+      </div>
+
+      {mode === 'access-attempt' && (
+        <AccessAttemptScreen
+          accessError={accessError}
+          inflight={inflight}
+          loadingScreenBrandHeight={loadingScreenBrandHeight}
+        />
+      )}
+
+      {mode === 'v1Import' && v1ImportData && (
+        <V1ImportDataScreen v1ImportData={v1ImportData} />
+      )}
+
+      {mode === 'requireAccept' && requireAcceptData && (
+        <RequireAcceptScreen
+          requireAcceptData={requireAcceptData}
+          sendPostRequest={sendPostRequest}
+        />
+      )}
+    </div>
+  )
+}
+
+export default withErrorBoundary(TokenAccessRoot, GenericErrorBoundaryFallback)

+ 84 - 0
services/web/frontend/js/features/token-access/components/v1-import-data-screen.tsx

@@ -0,0 +1,84 @@
+import { FC } from 'react'
+
+export type V1ImportData = {
+  name?: string
+  status: string
+  projectId: string
+}
+export const V1ImportDataScreen: FC<{ v1ImportData: V1ImportData }> = ({
+  v1ImportData,
+}) => {
+  return (
+    <div className="loading-screen">
+      <div className="container">
+        <div className="row">
+          <div className="col-sm-8 col-sm-offset-2">
+            <h1 className="text-center">
+              {v1ImportData.status === 'mustLogin'
+                ? 'Please log in'
+                : 'Overleaf v1 Project'}
+            </h1>
+
+            <img
+              className="v2-import__img"
+              src="/img/v1-import/v2-editor.png"
+              alt="The new V2 editor."
+            />
+
+            {v1ImportData.status === 'cannotImport' && (
+              <div>
+                <h2 className="text-center">
+                  Cannot Access Overleaf v1 Project
+                </h2>
+
+                <p className="text-center row-spaced-small">
+                  Please contact the project owner or{' '}
+                  <a href="/contact">contact support</a> for assistance.
+                </p>
+              </div>
+            )}
+
+            {v1ImportData.status === 'mustLogin' && (
+              <div>
+                <p className="text-center row-spaced-small">
+                  You will need to log in to access this project.
+                </p>
+
+                <div className="row-spaced text-center">
+                  <a
+                    className="btn btn-primary"
+                    href={`/login?redir=${encodeURIComponent(document.location.pathname)}`}
+                  >
+                    Log in to access project
+                  </a>
+                </div>
+              </div>
+            )}
+
+            {v1ImportData.status === 'canDownloadZip' && (
+              <div>
+                <p className="text-center row-spaced-small">
+                  <strong>{v1ImportData.name || 'This project'}</strong> has not
+                  yet been moved into the new version of Overleaf. This project
+                  was created anonymously and therefore cannot be automatically
+                  imported. Please download a zip file of the project and upload
+                  that to continue editing it. If you would like to delete this
+                  project after you have made a copy, please contact support.
+                </p>
+
+                <div className="row-spaced text-center">
+                  <a
+                    className="btn btn-primary"
+                    href={`/overleaf/project/${v1ImportData.projectId}/download/zip`}
+                  >
+                    Download project zip file
+                  </a>
+                </div>
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 13 - 0
services/web/frontend/js/pages/token-access.tsx

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

+ 4 - 0
services/web/frontend/js/shared/components/location.js

@@ -5,6 +5,10 @@ export const location = {
     // eslint-disable-next-line no-restricted-syntax
     window.location.assign(url)
   },
+  replace(url) {
+    // eslint-disable-next-line no-restricted-syntax
+    window.location.replace(url)
+  },
   reload() {
     // eslint-disable-next-line no-restricted-syntax
     window.location.reload()

+ 10 - 1
services/web/frontend/js/shared/hooks/use-location.ts

@@ -14,11 +14,20 @@ export const useLocation = () => {
     [isMounted]
   )
 
+  const replace = useCallback(
+    url => {
+      if (isMounted.current) {
+        location.replace(url)
+      }
+    },
+    [isMounted]
+  )
+
   const reload = useCallback(() => {
     if (isMounted.current) {
       location.reload()
     }
   }, [isMounted])
 
-  return useMemo(() => ({ assign, reload }), [assign, reload])
+  return useMemo(() => ({ assign, replace, reload }), [assign, replace, reload])
 }

+ 168 - 0
services/web/test/frontend/components/token-access/token-access-page.spec.tsx

@@ -0,0 +1,168 @@
+import TokenAccessPage from '@/features/token-access/components/token-access-root'
+import { location } from '@/shared/components/location'
+
+describe('<TokenAccessPage/>', function () {
+  // this is a URL for a read-only token, but the process is the same for read-write tokens
+  const url = '/read/123/grant'
+
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map<string, any>([
+        ['ol-postUrl', url],
+        ['ol-user', { email: 'test@example.com' }],
+      ])
+    })
+  })
+
+  it('handles a successful token access request', function () {
+    cy.intercept(
+      { method: 'post', url, times: 1 },
+      {
+        body: {
+          requireAccept: { projectName: 'Test Project' },
+        },
+      }
+    ).as('grantRequest')
+
+    cy.mount(<TokenAccessPage />)
+
+    cy.wait('@grantRequest').then(interception => {
+      expect(interception.request.body.confirmedByUser).to.be.false
+    })
+
+    cy.get('h1').should(
+      'have.text',
+      ['You have been invited to join', 'Test Project'].join('')
+    )
+
+    cy.contains('You are accepting this invite as test@example.com')
+
+    cy.intercept(
+      { method: 'post', url, times: 1 },
+      {
+        body: {
+          redirect: '/project/123',
+        },
+      }
+    ).as('confirmedGrantRequest')
+
+    cy.stub(location, 'replace').as('replaceLocation')
+
+    cy.findByRole('button', { name: 'Join Project' }).click()
+
+    cy.wait('@confirmedGrantRequest').then(interception => {
+      expect(interception.request.body.confirmedByUser).to.be.true
+    })
+
+    cy.get('@replaceLocation').should(
+      'have.been.calledOnceWith',
+      '/project/123'
+    )
+  })
+
+  it('handles a project not found response', function () {
+    cy.intercept({ method: 'post', url, times: 1 }, { statusCode: 404 }).as(
+      'grantRequest'
+    )
+
+    cy.mount(<TokenAccessPage />)
+
+    cy.wait('@grantRequest')
+
+    cy.get('h3').should('have.text', 'Join Project')
+    cy.get('h4').should('have.text', 'Project not found')
+
+    cy.findByRole('button', { name: 'Join Project' }).should('not.exist')
+  })
+
+  it('handles a redirect response', function () {
+    cy.intercept(
+      { method: 'post', url, times: 1 },
+      {
+        body: {
+          redirect: '/restricted',
+        },
+      }
+    ).as('grantRequest')
+
+    cy.stub(location, 'replace').as('replaceLocation')
+
+    cy.mount(<TokenAccessPage />)
+
+    cy.wait('@grantRequest')
+
+    cy.get('@replaceLocation').should('have.been.calledOnceWith', '/restricted')
+  })
+
+  it('handles a v1 "must login" response', function () {
+    cy.intercept(
+      { method: 'post', url, times: 1 },
+      {
+        body: {
+          v1Import: { status: 'mustLogin' },
+        },
+      }
+    ).as('grantRequest')
+
+    cy.stub(location, 'replace').as('replaceLocation')
+
+    cy.mount(<TokenAccessPage />)
+
+    cy.wait('@grantRequest')
+
+    cy.get('h1').should('have.text', 'Please log in')
+
+    cy.findByRole('link', { name: 'Log in to access project' })
+      .should('have.attr', 'href')
+      .and('match', /^\/login\?redir=/)
+  })
+
+  it('handles a v1 "cannot import" response', function () {
+    cy.intercept(
+      { method: 'post', url, times: 1 },
+      {
+        body: {
+          v1Import: { status: 'cannotImport' },
+        },
+      }
+    ).as('grantRequest')
+
+    cy.stub(location, 'replace').as('replaceLocation')
+
+    cy.mount(<TokenAccessPage />)
+
+    cy.wait('@grantRequest')
+
+    cy.get('h1').should('have.text', 'Overleaf v1 Project')
+    cy.get('h2').should('have.text', 'Cannot Access Overleaf v1 Project')
+  })
+
+  it('handles a v1 "can download zip" response', function () {
+    cy.intercept(
+      { method: 'post', url, times: 1 },
+      {
+        body: {
+          v1Import: {
+            status: 'canDownloadZip',
+            projectId: '123',
+            name: 'Test Project',
+          },
+        },
+      }
+    ).as('grantRequest')
+
+    cy.stub(location, 'replace').as('replaceLocation')
+
+    cy.mount(<TokenAccessPage />)
+
+    cy.wait('@grantRequest')
+
+    cy.get('h1').should('have.text', 'Overleaf v1 Project')
+
+    cy.findByRole('link', { name: 'Download project zip file' }).should(
+      'have.attr',
+      'href',
+      '/overleaf/project/123/download/zip'
+    )
+  })
+})

+ 1 - 0
services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx

@@ -14,6 +14,7 @@ describe('<ActionsCopyProject />', function () {
     assignStub = sinon.stub()
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: assignStub,
+      replace: sinon.stub(),
       reload: sinon.stub(),
     })
   })

+ 1 - 0
services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx

@@ -12,6 +12,7 @@ describe('<ModalContentNewProjectForm />', function () {
     assignStub = sinon.stub()
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: assignStub,
+      replace: sinon.stub(),
       reload: sinon.stub(),
     })
   })

+ 1 - 0
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -699,6 +699,7 @@ describe('<UserNotifications />', function () {
       assignStub = sinon.stub()
       this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
         assign: assignStub,
+        replace: sinon.stub(),
         reload: sinon.stub(),
       })
       fetchMock.reset()

+ 1 - 0
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -57,6 +57,7 @@ describe('<ProjectListRoot />', function () {
     assignStub = sinon.stub()
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: assignStub,
+      replace: sinon.stub(),
       reload: sinon.stub(),
     })
   })

+ 1 - 0
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx

@@ -17,6 +17,7 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
     assignStub = sinon.stub()
     locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: assignStub,
+      replace: sinon.stub(),
       reload: sinon.stub(),
     })
     render(

+ 1 - 0
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx

@@ -12,6 +12,7 @@ describe('<DownloadProjectButton />', function () {
     assignStub = sinon.stub()
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: assignStub,
+      replace: sinon.stub(),
       reload: sinon.stub(),
     })
     render(<DownloadProjectButtonTooltip project={projectsData[0]} />)

+ 1 - 0
services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

@@ -35,6 +35,7 @@ describe('<ReconfirmationInfo/>', function () {
     assignStub = sinon.stub()
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: assignStub,
+      replace: sinon.stub(),
       reload: sinon.stub(),
     })
   })

+ 1 - 0
services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx

@@ -61,6 +61,7 @@ describe('<LeaveModalForm />', function () {
       assignStub = sinon.stub()
       this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
         assign: assignStub,
+        replace: sinon.stub(),
         reload: sinon.stub(),
       })
       window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })

+ 1 - 0
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx

@@ -88,6 +88,7 @@ describe('<ShareProjectModal/>', function () {
   beforeEach(function () {
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: sinon.stub(),
+      replace: sinon.stub(),
       reload: sinon.stub(),
     })
     fetchMock.get('/user/contacts', { contacts })

+ 1 - 0
services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx

@@ -82,6 +82,7 @@ describe('<GroupSubscriptionMemberships />', function () {
       reloadStub = sinon.stub()
       this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
         assign: sinon.stub(),
+        replace: sinon.stub(),
         reload: reloadStub,
       })
 

+ 1 - 0
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -54,6 +54,7 @@ describe('<PersonalSubscription />', function () {
       reloadStub = sinon.stub()
       this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
         assign: sinon.stub(),
+        replace: sinon.stub(),
         reload: reloadStub,
       })
     })

+ 1 - 0
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -196,6 +196,7 @@ describe('<ActiveSubscription />', function () {
     beforeEach(function () {
       this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
         assign: assignStub,
+        replace: sinon.stub(),
         reload: reloadStub,
       })
     })

+ 1 - 0
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -31,6 +31,7 @@ describe('<ChangePlanModal />', function () {
     reloadStub = sinon.stub()
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
       assign: sinon.stub(),
+      replace: sinon.stub(),
       reload: reloadStub,
     })
   })

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

@@ -66,6 +66,12 @@ describe('TokenAccessController', function () {
       },
     }
 
+    this.SplitTestHandler = {
+      promises: {
+        getAssignment: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
+
     this.TokenAccessController = SandboxedModule.require(MODULE_PATH, {
       requires: {
         '@overleaf/settings': this.Settings,
@@ -77,6 +83,7 @@ describe('TokenAccessController', function () {
         '../Authorization/AuthorizationMiddleware':
           this.AuthorizationMiddleware,
         '../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler,
+        '../SplitTests/SplitTestHandler': this.SplitTestHandler,
         '../Errors/Errors': (this.Errors = { NotFoundError: sinon.stub() }),
       },
     })