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

Merge pull request #18188 from overleaf/jel-react-group-invite

[web] Migrate team invite to React

GitOrigin-RevId: 32e968c3b512020aef9a396808c73a7b4859e6d1
Jessica Lawshe 2 лет назад
Родитель
Сommit
c6b88085d5
18 измененных файлов с 800 добавлено и 24 удалено
  1. 31 11
      services/web/app/src/Features/Subscription/TeamInvitesController.js
  2. 17 0
      services/web/app/views/subscriptions/team/invite-react.pug
  3. 5 0
      services/web/frontend/extracted-translations.json
  4. 1 7
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx
  5. 24 0
      services/web/frontend/js/features/subscription/components/group-invite/accepted-invite.tsx
  6. 97 0
      services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx
  7. 70 0
      services/web/frontend/js/features/subscription/components/group-invite/has-individual-recurly-subscription.tsx
  8. 73 0
      services/web/frontend/js/features/subscription/components/group-invite/join-group.tsx
  9. 31 0
      services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx
  10. 11 0
      services/web/frontend/js/features/subscription/components/invite-root.tsx
  11. 9 0
      services/web/frontend/js/pages/user/subscription/invite.tsx
  12. 2 4
      services/web/frontend/stylesheets/app/subscription.less
  13. 39 0
      services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx
  14. 125 0
      services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx
  15. 48 0
      services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx
  16. 48 0
      services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx
  17. 21 0
      services/web/test/frontend/features/subscription/components/group-invite/managed-user-cannot-join.test.tsx
  18. 148 2
      services/web/test/unit/src/Subscription/TeamInvitesControllerTests.js

+ 31 - 11
services/web/app/src/Features/Subscription/TeamInvitesController.js

@@ -14,6 +14,7 @@ const EmailHandler = require('../Email/EmailHandler')
 const { RateLimiter } = require('../../infrastructure/RateLimiter')
 const { RateLimiter } = require('../../infrastructure/RateLimiter')
 const Modules = require('../../infrastructure/Modules')
 const Modules = require('../../infrastructure/Modules')
 const UserAuditLogHandler = require('../User/UserAuditLogHandler')
 const UserAuditLogHandler = require('../User/UserAuditLogHandler')
+const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 
 
 const rateLimiters = {
 const rateLimiters = {
   resendGroupInvite: new RateLimiter('resend-group-invite', {
   resendGroupInvite: new RateLimiter('resend-group-invite', {
@@ -72,6 +73,12 @@ async function viewInvite(req, res, next) {
     return ErrorController.notFound(req, res)
     return ErrorController.notFound(req, res)
   }
   }
 
 
+  const { variant } = await SplitTestHandler.promises.getAssignment(
+    req,
+    res,
+    'team-invite-react'
+  )
+
   let validationStatus = new Map()
   let validationStatus = new Map()
   if (userId) {
   if (userId) {
     const personalSubscription =
     const personalSubscription =
@@ -145,17 +152,30 @@ async function viewInvite(req, res, next) {
         logger.error({ err }, 'error getting subscription admin email')
         logger.error({ err }, 'error getting subscription admin email')
       }
       }
 
 
-      return res.render('subscriptions/team/invite', {
-        inviterName: invite.inviterName,
-        inviteToken: invite.token,
-        hasIndividualRecurlySubscription,
-        appName: settings.appName,
-        expired: req.query.expired,
-        userRestrictions: Array.from(req.userRestrictions || []),
-        currentManagedUserAdminEmail,
-        groupSSOActive,
-        subscriptionId: subscription._id.toString(),
-      })
+      if (variant === 'enabled') {
+        return res.render('subscriptions/team/invite-react', {
+          inviterName: invite.inviterName,
+          inviteToken: invite.token,
+          hasIndividualRecurlySubscription,
+          expired: req.query.expired,
+          userRestrictions: Array.from(req.userRestrictions || []),
+          currentManagedUserAdminEmail,
+          groupSSOActive,
+          subscriptionId: subscription._id.toString(),
+        })
+      } else {
+        return res.render('subscriptions/team/invite', {
+          inviterName: invite.inviterName,
+          inviteToken: invite.token,
+          hasIndividualRecurlySubscription,
+          appName: settings.appName,
+          expired: req.query.expired,
+          userRestrictions: Array.from(req.userRestrictions || []),
+          currentManagedUserAdminEmail,
+          groupSSOActive,
+          subscriptionId: subscription._id.toString(),
+        })
+      }
     }
     }
   } else {
   } else {
     const userByEmail = await UserGetter.promises.getUserByMainEmail(
     const userByEmail = await UserGetter.promises.getUserByMainEmail(

+ 17 - 0
services/web/app/views/subscriptions/team/invite-react.pug

@@ -0,0 +1,17 @@
+extends ../../layout-marketing
+
+block entrypointVar
+	- entrypoint = 'pages/user/subscription/invite'
+
+
+block append meta
+	meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription)
+	meta(name="ol-inviterName" date-type="string" content=inviterName)
+	meta(name="ol-inviteToken" data-type="string" content=inviteToken)
+	meta(name="ol-currentManagedUserAdminEmail" data-type="string" content=currentManagedUserAdminEmail)
+	meta(name="ol-expired" data-type="boolean" content=expired)
+	meta(name="ol-groupSSOActive" data-type="boolean" content=groupSSOActive)
+	meta(name="ol-subscriptionId" data-type="string" content=subscriptionId)
+
+block content
+	main.content.content-alt#invite-root

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

@@ -119,6 +119,7 @@
   "cancel_anytime": "",
   "cancel_anytime": "",
   "cancel_my_account": "",
   "cancel_my_account": "",
   "cancel_my_subscription": "",
   "cancel_my_subscription": "",
+  "cancel_personal_subscription_first": "",
   "cancel_your_subscription": "",
   "cancel_your_subscription": "",
   "cannot_invite_non_user": "",
   "cannot_invite_non_user": "",
   "cannot_invite_self": "",
   "cannot_invite_self": "",
@@ -630,6 +631,7 @@
   "join_now": "",
   "join_now": "",
   "join_project": "",
   "join_project": "",
   "join_team_explanation": "",
   "join_team_explanation": "",
+  "joined_team": "",
   "joining": "",
   "joining": "",
   "justify": "",
   "justify": "",
   "keep_current_plan": "",
   "keep_current_plan": "",
@@ -1178,6 +1180,7 @@
   "skip": "",
   "skip": "",
   "something_not_right": "",
   "something_not_right": "",
   "something_went_wrong": "",
   "something_went_wrong": "",
+  "something_went_wrong_canceling_your_subscription": "",
   "something_went_wrong_loading_pdf_viewer": "",
   "something_went_wrong_loading_pdf_viewer": "",
   "something_went_wrong_processing_the_request": "",
   "something_went_wrong_processing_the_request": "",
   "something_went_wrong_rendering_pdf": "",
   "something_went_wrong_rendering_pdf": "",
@@ -1559,6 +1562,7 @@
   "you_can_now_enable_sso": "",
   "you_can_now_enable_sso": "",
   "you_can_now_log_in_sso": "",
   "you_can_now_log_in_sso": "",
   "you_cant_add_or_change_password_due_to_sso": "",
   "you_cant_add_or_change_password_due_to_sso": "",
+  "you_cant_join_this_group_subscription": "",
   "you_dont_have_any_repositories": "",
   "you_dont_have_any_repositories": "",
   "you_have_added_x_of_group_size_y": "",
   "you_have_added_x_of_group_size_y": "",
   "you_have_been_invited_to_transfer_management_of_your_account": "",
   "you_have_been_invited_to_transfer_management_of_your_account": "",
@@ -1569,6 +1573,7 @@
   "youll_get_best_results_in_visual_but_can_be_used_in_source": "",
   "youll_get_best_results_in_visual_but_can_be_used_in_source": "",
   "youll_need_to_ask_the_github_repository_owner": "",
   "youll_need_to_ask_the_github_repository_owner": "",
   "youll_no_longer_need_to_remember_credentials": "",
   "youll_no_longer_need_to_remember_credentials": "",
+  "your_account_is_managed_by_admin_cant_join_additional_group": "",
   "your_affiliation_is_confirmed": "",
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_browser_does_not_support_this_feature": "",
   "your_compile_timed_out": "",
   "your_compile_timed_out": "",

+ 1 - 7
services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx

@@ -101,13 +101,7 @@ export function useGroupInvitationNotification(
   const cancelPersonalSubscription = useCallback(() => {
   const cancelPersonalSubscription = useCallback(() => {
     setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
     setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
 
 
-    runAsync(
-      postJSON('/user/subscription/cancel', {
-        body: {
-          _csrf: getMeta('ol-csrfToken'),
-        },
-      })
-    ).catch(debugConsole.error)
+    runAsync(postJSON('/user/subscription/cancel')).catch(debugConsole.error)
   }, [runAsync])
   }, [runAsync])
 
 
   const dismissGroupInviteNotification = useCallback(() => {
   const dismissGroupInviteNotification = useCallback(() => {

+ 24 - 0
services/web/frontend/js/features/subscription/components/group-invite/accepted-invite.tsx

@@ -0,0 +1,24 @@
+import getMeta from '@/utils/meta'
+import { useTranslation } from 'react-i18next'
+
+export default function AcceptedInvite() {
+  const { t } = useTranslation()
+  const inviterName = getMeta('ol-inviterName') as string
+  const groupSSOActive = getMeta('ol-groupSSOActive') as boolean
+  const subscriptionId = getMeta('ol-subscriptionId') as string
+
+  const doneLink = groupSSOActive
+    ? `/subscription/${subscriptionId}/sso_enrollment`
+    : '/project'
+
+  return (
+    <div className="text-center">
+      <p>{t('joined_team', { inviterName })}</p>
+      <p>
+        <a href={doneLink} className="btn btn-primary">
+          {t('done')}
+        </a>
+      </p>
+    </div>
+  )
+}

+ 97 - 0
services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx

@@ -0,0 +1,97 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import getMeta from '@/utils/meta'
+import HasIndividualRecurlySubscription from './has-individual-recurly-subscription'
+import { useEffect, useState } from 'react'
+import { useTranslation, Trans } from 'react-i18next'
+import ManagedUserCannotJoin from './managed-user-cannot-join'
+import Notification from '@/shared/components/notification'
+import JoinGroup from './join-group'
+import AcceptedInvite from './accepted-invite'
+
+export type InviteViewTypes =
+  | 'invite'
+  | 'invite-accepted'
+  | 'cancel-personal-subscription'
+  | 'managed-user-cannot-join'
+  | undefined
+
+function GroupInviteViews() {
+  const hasIndividualRecurlySubscription = getMeta(
+    'ol-hasIndividualRecurlySubscription'
+  ) as boolean
+  const cannotJoinSubscription = getMeta(
+    'ol-cannot-join-subscription'
+  ) as boolean
+
+  useEffect(() => {
+    if (cannotJoinSubscription) {
+      setView('managed-user-cannot-join')
+    } else if (hasIndividualRecurlySubscription) {
+      setView('cancel-personal-subscription')
+    } else {
+      setView('invite')
+    }
+  }, [cannotJoinSubscription, hasIndividualRecurlySubscription])
+  const [view, setView] = useState<InviteViewTypes>(undefined)
+
+  if (!view) {
+    return null
+  }
+
+  if (view === 'managed-user-cannot-join') {
+    return <ManagedUserCannotJoin />
+  } else if (view === 'cancel-personal-subscription') {
+    return <HasIndividualRecurlySubscription setView={setView} />
+  } else if (view === 'invite') {
+    return <JoinGroup setView={setView} />
+  } else if (view === 'invite-accepted') {
+    return <AcceptedInvite />
+  }
+
+  return null
+}
+
+export default function GroupInvite() {
+  const inviterName = getMeta('ol-inviterName') as string
+  const expired = getMeta('ol-expired') as boolean
+  const { isReady } = useWaitForI18n()
+  const { t } = useTranslation()
+
+  if (!isReady) {
+    return null
+  }
+
+  return (
+    <div className="container" id="main-content">
+      {expired && (
+        <div className="row">
+          <div className="col-md-8 col-md-offset-2">
+            <Notification type="error" content={t('email_link_expired')} />
+          </div>
+        </div>
+      )}
+
+      <div className="row row-spaced">
+        <div className="col-md-8 col-md-offset-2">
+          <div className="card">
+            <div className="page-header">
+              <h1 className="text-center">
+                <Trans
+                  i18nKey="invited_to_group"
+                  values={{ inviterName }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
+                  components={
+                    /* eslint-disable-next-line react/jsx-key */
+                    [<span className="team-invite-name" />]
+                  }
+                />
+              </h1>
+            </div>
+            <GroupInviteViews />
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 70 - 0
services/web/frontend/js/features/subscription/components/group-invite/has-individual-recurly-subscription.tsx

@@ -0,0 +1,70 @@
+import { FetchError, postJSON } from '@/infrastructure/fetch-json'
+import Notification from '@/shared/components/notification'
+import useAsync from '@/shared/hooks/use-async'
+import { debugConsole } from '@/utils/debugging'
+import getMeta from '@/utils/meta'
+import { Dispatch, SetStateAction, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { InviteViewTypes } from './group-invite'
+
+export default function HasIndividualRecurlySubscription({
+  setView,
+}: {
+  setView: Dispatch<SetStateAction<InviteViewTypes>>
+}) {
+  const { t } = useTranslation()
+  const {
+    runAsync,
+    isLoading: isCancelling,
+    isError,
+  } = useAsync<never, FetchError>()
+
+  const cancelPersonalSubscription = useCallback(() => {
+    runAsync(
+      postJSON('/user/subscription/cancel', {
+        body: {
+          _csrf: getMeta('ol-csrfToken'),
+        },
+      })
+    )
+      .then(() => {
+        setView('invite')
+      })
+      .catch(debugConsole.error)
+  }, [runAsync, setView])
+
+  return (
+    <>
+      {isError && (
+        <Notification
+          type="error"
+          content={t('something_went_wrong_canceling_your_subscription')}
+          className="my-3"
+        />
+      )}
+
+      <div className="text-center">
+        <p>{t('cancel_personal_subscription_first')}</p>
+        <p>
+          <button
+            className="btn btn-secondary"
+            disabled={isCancelling}
+            onClick={() => setView('invite')}
+          >
+            {t('not_now')}
+          </button>
+          &nbsp;&nbsp;
+          <button
+            className="btn btn-primary"
+            disabled={isCancelling}
+            onClick={() => {
+              cancelPersonalSubscription()
+            }}
+          >
+            {t('cancel_your_subscription')}
+          </button>
+        </p>
+      </div>
+    </>
+  )
+}

+ 73 - 0
services/web/frontend/js/features/subscription/components/group-invite/join-group.tsx

@@ -0,0 +1,73 @@
+import { Dispatch, SetStateAction, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { InviteViewTypes } from './group-invite'
+import getMeta from '@/utils/meta'
+import { FetchError, putJSON } from '@/infrastructure/fetch-json'
+import useAsync from '@/shared/hooks/use-async'
+import classNames from 'classnames'
+import { debugConsole } from '@/utils/debugging'
+import Notification from '@/shared/components/notification'
+
+export default function JoinGroup({
+  setView,
+}: {
+  setView: Dispatch<SetStateAction<InviteViewTypes>>
+}) {
+  const { t } = useTranslation()
+  const expired = getMeta('ol-expired') as boolean
+  const inviteToken = getMeta('ol-inviteToken') as string
+  const {
+    runAsync,
+    isLoading: isJoining,
+    isError,
+  } = useAsync<never, FetchError>()
+
+  const notNowBtnClasses = classNames(
+    'btn',
+    'btn-secondary',
+    isJoining ? 'disabled' : ''
+  )
+
+  const joinTeam = useCallback(() => {
+    runAsync(putJSON(`/subscription/invites/${inviteToken}`))
+      .then(() => {
+        setView('invite-accepted')
+      })
+      .catch(debugConsole.error)
+  }, [inviteToken, runAsync, setView])
+
+  if (!inviteToken) {
+    return null
+  }
+
+  return (
+    <>
+      {isError && (
+        <Notification
+          type="error"
+          content={t('generic_something_went_wrong')}
+          className="my-3"
+        />
+      )}
+
+      <div className="text-center">
+        <p>{t('join_team_explanation')}</p>
+        {!expired && (
+          <p>
+            <a className={notNowBtnClasses} href="/project">
+              {t('not_now')}
+            </a>
+            &nbsp;&nbsp;
+            <button
+              className="btn btn-primary"
+              onClick={() => joinTeam()}
+              disabled={isJoining}
+            >
+              {t('accept_invitation')}
+            </button>
+          </p>
+        )}
+      </div>
+    </>
+  )
+}

+ 31 - 0
services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx

@@ -0,0 +1,31 @@
+import { Trans, useTranslation } from 'react-i18next'
+import Notification from '@/shared/components/notification'
+import getMeta from '@/utils/meta'
+
+export default function ManagedUserCannotJoin() {
+  const { t } = useTranslation()
+  const currentManagedUserAdminEmail = getMeta(
+    'ol-currentManagedUserAdminEmail'
+  ) as string
+
+  return (
+    <Notification
+      type="info"
+      title={t('you_cant_join_this_group_subscription')}
+      content={
+        <p>
+          <Trans
+            i18nKey="your_account_is_managed_by_admin_cant_join_additional_group"
+            values={{ admin: currentManagedUserAdminEmail }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+            components={[
+              /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+              <a href="/learn/how-to/Understanding_Managed_Overleaf_Accounts" />,
+            ]}
+          />
+        </p>
+      }
+    />
+  )
+}

+ 11 - 0
services/web/frontend/js/features/subscription/components/invite-root.tsx

@@ -0,0 +1,11 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import GroupInvite from './group-invite/group-invite'
+
+export default function InviteRoot() {
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+  return <GroupInvite />
+}

+ 9 - 0
services/web/frontend/js/pages/user/subscription/invite.tsx

@@ -0,0 +1,9 @@
+import './base'
+import ReactDOM from 'react-dom'
+import InviteRoot from '@/features/subscription/components/invite-root'
+
+const element = document.getElementById('invite-root')
+
+if (element) {
+  ReactDOM.render(<InviteRoot />, element)
+}

+ 2 - 4
services/web/frontend/stylesheets/app/subscription.less

@@ -77,10 +77,8 @@
   margin: 3em 0;
   margin: 3em 0;
 }
 }
 
 
-.team-invite {
-  .team-invite-name {
-    word-break: break-word;
-  }
+.team-invite-name {
+  word-break: break-word;
 }
 }
 
 
 .capitalised {
 .capitalised {

+ 39 - 0
services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx

@@ -0,0 +1,39 @@
+import { render, screen } from '@testing-library/react'
+import AcceptedInvite from '../../../../../../frontend/js/features/subscription/components/group-invite/accepted-invite'
+import { expect } from 'chai'
+
+describe('accepted group invite', function () {
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('renders', async function () {
+    window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
+    render(<AcceptedInvite />)
+    await screen.findByText(
+      'You have joined the group subscription managed by example@overleaf.com'
+    )
+  })
+
+  it('links to SSO enrollment page for SSO groups', async function () {
+    window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
+    window.metaAttributesCache.set('ol-groupSSOActive', true)
+    window.metaAttributesCache.set('ol-subscriptionId', 'group123')
+    render(<AcceptedInvite />)
+    const linkBtn = (await screen.findByRole('link', {
+      name: 'Done',
+    })) as HTMLLinkElement
+    expect(linkBtn.href).to.equal(
+      'https://www.test-overleaf.com/subscription/group123/sso_enrollment'
+    )
+  })
+
+  it('links to dash for non-SSO groups', async function () {
+    window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
+    render(<AcceptedInvite />)
+    const linkBtn = (await screen.findByRole('link', {
+      name: 'Done',
+    })) as HTMLLinkElement
+    expect(linkBtn.href).to.equal('https://www.test-overleaf.com/project')
+  })
+})

+ 125 - 0
services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx

@@ -0,0 +1,125 @@
+import { render, screen } from '@testing-library/react'
+import { expect } from 'chai'
+import GroupInvite from '../../../../../../frontend/js/features/subscription/components/group-invite/group-invite'
+
+describe('group invite', function () {
+  const inviterName = 'example@overleaf.com'
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-inviterName', inviterName)
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('renders header', async function () {
+    render(<GroupInvite />)
+    await screen.findByText(inviterName)
+    screen.getByText(`has invited you to join a group subscription on Overleaf`)
+    expect(screen.queryByText('Email link expired, please request a new one.'))
+      .to.be.null
+  })
+
+  describe('when user has personal subscription', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set(
+        'ol-hasIndividualRecurlySubscription',
+        true
+      )
+    })
+
+    it('renders cancel personal subscription view', async function () {
+      render(<GroupInvite />)
+      await screen.findByText(
+        'You already have an individual subscription, would you like us to cancel this first before joining the group licence?'
+      )
+    })
+
+    describe('and in a managed group', function () {
+      // note: this should not be possible but managed user view takes priority over all
+      beforeEach(function () {
+        window.metaAttributesCache.set(
+          'ol-currentManagedUserAdminEmail',
+          'example@overleaf.com'
+        )
+        window.metaAttributesCache.set('ol-cannot-join-subscription', true)
+      })
+
+      it('renders managed user cannot join view', async function () {
+        render(<GroupInvite />)
+        await screen.findByText('You can’t join this group subscription')
+        screen.getByText(
+          'Your Overleaf account is managed by your current group admin (example@overleaf.com). This means you can’t join additional group subscriptions',
+          { exact: false }
+        )
+        screen.getByRole('link', { name: 'Read more about Managed Users.' })
+      })
+    })
+  })
+
+  describe('when user does not have a personal subscription', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set(
+        'ol-hasIndividualRecurlySubscription',
+        false
+      )
+      window.metaAttributesCache.set('ol-inviteToken', 'token123')
+    })
+
+    it('does not render cancel personal subscription view', async function () {
+      render(<GroupInvite />)
+      await screen.findByText(
+        'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
+      )
+    })
+  })
+
+  describe('when the user is already a managed user in another group', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set(
+        'ol-currentManagedUserAdminEmail',
+        'example@overleaf.com'
+      )
+      window.metaAttributesCache.set('ol-cannot-join-subscription', true)
+    })
+
+    it('renders managed user cannot join view', async function () {
+      render(<GroupInvite />)
+      await screen.findByText(inviterName)
+      screen.getByText(
+        `has invited you to join a group subscription on Overleaf`
+      )
+      screen.getByText('You can’t join this group subscription')
+      screen.getByText(
+        'Your Overleaf account is managed by your current group admin (example@overleaf.com). This means you can’t join additional group subscriptions',
+        { exact: false }
+      )
+      screen.getByRole('link', { name: 'Read more about Managed Users.' })
+    })
+  })
+
+  describe('expired', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-expired', true)
+    })
+
+    it('shows error notification when expired', async function () {
+      render(<GroupInvite />)
+      await screen.findByText('Email link expired, please request a new one.')
+    })
+  })
+
+  describe('join view', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-inviteToken', 'token123')
+    })
+
+    it('shows view to join group', async function () {
+      render(<GroupInvite />)
+      await screen.findByText(
+        'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
+      )
+    })
+  })
+})

+ 48 - 0
services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx

@@ -0,0 +1,48 @@
+import { expect } from 'chai'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import HasIndividualRecurlySubscription from '../../../../../../frontend/js/features/subscription/components/group-invite/has-individual-recurly-subscription'
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
+
+describe('group invite', function () {
+  describe('user has a personal subscription', function () {
+    afterEach(function () {
+      fetchMock.reset()
+    })
+
+    it('shows option to cancel subscription', async function () {
+      render(<HasIndividualRecurlySubscription setView={() => {}} />)
+      await screen.findByText(
+        'You already have an individual subscription, would you like us to cancel this first before joining the group licence?'
+      )
+      screen.getByRole('button', { name: 'Not now' })
+      screen.getByRole('button', { name: 'Cancel Your Subscription' })
+    })
+
+    it('handles subscription cancellation and calls to change invite view', async function () {
+      fetchMock.post('/user/subscription/cancel', 200)
+      const setView = sinon.stub()
+      render(<HasIndividualRecurlySubscription setView={setView} />)
+      const button = await screen.findByRole('button', {
+        name: 'Cancel Your Subscription',
+      })
+      fireEvent.click(button)
+      await waitFor(() => {
+        expect(setView).to.have.been.calledOnce
+      })
+    })
+
+    it('shows error message when cancelling subscription fails', async function () {
+      render(<HasIndividualRecurlySubscription setView={() => {}} />)
+      const button = await screen.findByRole('button', {
+        name: 'Cancel Your Subscription',
+      })
+      fireEvent.click(button)
+      await waitFor(() => {
+        screen.getByText(
+          'Something went wrong canceling your subscription. Please contact support.'
+        )
+      })
+    })
+  })
+})

+ 48 - 0
services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx

@@ -0,0 +1,48 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { expect } from 'chai'
+import JoinGroup from '../../../../../../frontend/js/features/subscription/components/group-invite/join-group'
+import fetchMock from 'fetch-mock'
+import sinon from 'sinon'
+
+describe('join group', function () {
+  const inviteToken = 'token123'
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-inviteToken', inviteToken)
+  })
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows option to join subscription', async function () {
+    render(<JoinGroup setView={() => {}} />)
+    await screen.findByText(
+      'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
+    )
+    screen.getByRole('link', { name: 'Not now' })
+    screen.getByRole('button', { name: 'Accept invitation' })
+  })
+
+  it('handles success when accepting invite', async function () {
+    fetchMock.put(`/subscription/invites/${inviteToken}`, 200)
+    const setView = sinon.stub()
+    render(<JoinGroup setView={setView} />)
+    const button = await screen.getByRole('button', {
+      name: 'Accept invitation',
+    })
+    fireEvent.click(button)
+    await waitFor(() => {
+      expect(setView).to.have.been.calledOnce
+    })
+  })
+
+  it('handles errors when accepting invite', async function () {
+    render(<JoinGroup setView={() => {}} />)
+    const button = await screen.getByRole('button', {
+      name: 'Accept invitation',
+    })
+    fireEvent.click(button)
+    await waitFor(() => {
+      screen.getByText('Sorry, something went wrong')
+    })
+  })
+})

+ 21 - 0
services/web/test/frontend/features/subscription/components/group-invite/managed-user-cannot-join.test.tsx

@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react'
+import ManagedUserCannotJoin from '../../../../../../frontend/js/features/subscription/components/group-invite/managed-user-cannot-join'
+
+describe('ManagedUserCannotJoin', function () {
+  beforeEach(function () {
+    window.metaAttributesCache.set(
+      'ol-currentManagedUserAdminEmail',
+      'example@overleaf.com'
+    )
+    window.metaAttributesCache.set('ol-cannot-join-subscription', true)
+  })
+
+  it('renders the component', async function () {
+    render(<ManagedUserCannotJoin />)
+    await screen.findByText(
+      'Your Overleaf account is managed by your current group admin (example@overleaf.com). This means you can’t join additional group subscriptions',
+      { exact: false }
+    )
+    screen.getByRole('link', { name: 'Read more about Managed Users.' })
+  })
+})

+ 148 - 2
services/web/test/unit/src/Subscription/TeamInvitesControllerTests.js

@@ -1,5 +1,6 @@
 const SandboxedModule = require('sandboxed-module')
 const SandboxedModule = require('sandboxed-module')
 const sinon = require('sinon')
 const sinon = require('sinon')
+const { expect } = require('chai')
 const modulePath =
 const modulePath =
   '../../../../app/src/Features/Subscription/TeamInvitesController'
   '../../../../app/src/Features/Subscription/TeamInvitesController'
 
 
@@ -26,19 +27,30 @@ describe('TeamInvitesController', function () {
     }
     }
 
 
     this.TeamInvitesHandler = {
     this.TeamInvitesHandler = {
-      promises: { acceptInvite: sinon.stub().resolves(this.subscription) },
+      promises: {
+        acceptInvite: sinon.stub().resolves(this.subscription),
+        getInvite: sinon.stub().resolves({
+          invite: {
+            email: this.user.email,
+            token: 'token123',
+            inviterName: this.user_email,
+          },
+          subscription: this.subscription,
+        }),
+      },
     }
     }
 
 
     this.SubscriptionLocator = {
     this.SubscriptionLocator = {
       promises: {
       promises: {
         hasSSOEnabled: sinon.stub().resolves(true),
         hasSSOEnabled: sinon.stub().resolves(true),
+        getUsersSubscription: sinon.stub().resolves(),
       },
       },
     }
     }
     this.ErrorController = { notFound: sinon.stub() }
     this.ErrorController = { notFound: sinon.stub() }
 
 
     this.SessionManager = {
     this.SessionManager = {
       getLoggedInUserId(session) {
       getLoggedInUserId(session) {
-        return session.user._id
+        return session.user?._id
       },
       },
       getSessionUser(session) {
       getSessionUser(session) {
         return session.user
         return session.user
@@ -75,6 +87,18 @@ describe('TeamInvitesController', function () {
         '../User/UserGetter': this.UserGetter,
         '../User/UserGetter': this.UserGetter,
         '../Email/EmailHandler': this.EmailHandler,
         '../Email/EmailHandler': this.EmailHandler,
         '../../infrastructure/RateLimiter': this.RateLimiter,
         '../../infrastructure/RateLimiter': this.RateLimiter,
+        '../../infrastructure/Modules': (this.Modules = {
+          promises: {
+            hooks: {
+              fire: sinon.stub().resolves([]),
+            },
+          },
+        }),
+        '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
+          promises: {
+            getAssignment: sinon.stub().resolves({}),
+          },
+        }),
       },
       },
     })
     })
   })
   })
@@ -99,4 +123,126 @@ describe('TeamInvitesController', function () {
       this.Controller.acceptInvite(this.req, res)
       this.Controller.acceptInvite(this.req, res)
     })
     })
   })
   })
+
+  describe('viewInvite', function () {
+    const req = {
+      params: { token: 'token123' },
+      query: {},
+      session: {
+        user: { _id: 'user123' },
+      },
+    }
+
+    describe('hasIndividualRecurlySubscription', function () {
+      it('is true for personal subscription', function (done) {
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves({
+          recurlySubscription_id: 'subscription123',
+          groupPlan: false,
+        })
+        const res = {
+          render: (template, data) => {
+            expect(data.hasIndividualRecurlySubscription).to.be.true
+            done()
+          },
+        }
+        this.Controller.viewInvite(req, res)
+      })
+
+      it('is true for group subscriptions', function (done) {
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves({
+          recurlySubscription_id: 'subscription123',
+          groupPlan: true,
+        })
+        const res = {
+          render: (template, data) => {
+            expect(data.hasIndividualRecurlySubscription).to.be.false
+            done()
+          },
+        }
+        this.Controller.viewInvite(req, res)
+      })
+
+      it('is false for canceled subscriptions', function (done) {
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves({
+          recurlySubscription_id: 'subscription123',
+          groupPlan: false,
+          recurlyStatus: {
+            state: 'canceled',
+          },
+        })
+        const res = {
+          render: (template, data) => {
+            expect(data.hasIndividualRecurlySubscription).to.be.false
+            done()
+          },
+        }
+        this.Controller.viewInvite(req, res)
+      })
+    })
+
+    describe('when user is logged out', function () {
+      it('renders logged out invite page', function (done) {
+        const res = {
+          render: template => {
+            expect(template).to.equal('subscriptions/team/invite_logged_out')
+            done()
+          },
+        }
+        this.Controller.viewInvite(
+          { params: { token: 'token123' }, session: {} },
+          res
+        )
+      })
+    })
+
+    describe('Feature rollout of React migration', function () {
+      describe('feature rollout is not active', function () {
+        it('renders old Angular template', function (done) {
+          const res = {
+            render: template => {
+              expect(template).to.equal('subscriptions/team/invite')
+              done()
+            },
+          }
+          this.Controller.viewInvite(req, res)
+        })
+      })
+
+      describe('user is on default variant', function () {
+        beforeEach(function () {
+          this.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'default',
+          })
+        })
+
+        it('renders old Angular template', function (done) {
+          const res = {
+            render: template => {
+              expect(template).to.equal('subscriptions/team/invite')
+              done()
+            },
+          }
+          this.Controller.viewInvite(req, res)
+        })
+      })
+
+      describe('user is on enabled variant', function () {
+        beforeEach(function () {
+          this.SplitTestHandler.promises.getAssignment.resolves({
+            variant: 'enabled',
+          })
+        })
+
+        it('renders React template', function (done) {
+          const res = {
+            render: template => {
+              expect(template).to.equal('subscriptions/team/invite-react')
+              done()
+            },
+          }
+          this.Controller.viewInvite(req, res)
+        })
+      })
+    })
+  })
 })
 })