From c6b88085d530461de433f3b1e142c6d366b72ef4 Mon Sep 17 00:00:00 2001
From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com>
Date: Wed, 8 May 2024 08:43:25 -0500
Subject: [PATCH] Merge pull request #18188 from
overleaf/jel-react-group-invite
[web] Migrate team invite to React
GitOrigin-RevId: 32e968c3b512020aef9a396808c73a7b4859e6d1
---
.../Subscription/TeamInvitesController.js | 42 +++--
.../views/subscriptions/team/invite-react.pug | 17 ++
.../web/frontend/extracted-translations.json | 5 +
.../use-group-invitation-notification.tsx | 8 +-
.../group-invite/accepted-invite.tsx | 24 +++
.../components/group-invite/group-invite.tsx | 97 +++++++++++
.../has-individual-recurly-subscription.tsx | 70 ++++++++
.../components/group-invite/join-group.tsx | 73 +++++++++
.../group-invite/managed-user-cannot-join.tsx | 31 ++++
.../subscription/components/invite-root.tsx | 11 ++
.../js/pages/user/subscription/invite.tsx | 9 ++
.../stylesheets/app/subscription.less | 6 +-
.../group-invite/accepted-invite.test.tsx | 39 +++++
.../group-invite/group-invite.test.tsx | 125 +++++++++++++++
...s-individual-recurly-subscription.test.tsx | 48 ++++++
.../group-invite/join-group.test.tsx | 48 ++++++
.../managed-user-cannot-join.test.tsx | 21 +++
.../TeamInvitesControllerTests.js | 150 +++++++++++++++++-
18 files changed, 800 insertions(+), 24 deletions(-)
create mode 100644 services/web/app/views/subscriptions/team/invite-react.pug
create mode 100644 services/web/frontend/js/features/subscription/components/group-invite/accepted-invite.tsx
create mode 100644 services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx
create mode 100644 services/web/frontend/js/features/subscription/components/group-invite/has-individual-recurly-subscription.tsx
create mode 100644 services/web/frontend/js/features/subscription/components/group-invite/join-group.tsx
create mode 100644 services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx
create mode 100644 services/web/frontend/js/features/subscription/components/invite-root.tsx
create mode 100644 services/web/frontend/js/pages/user/subscription/invite.tsx
create mode 100644 services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx
create mode 100644 services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx
create mode 100644 services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx
create mode 100644 services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx
create mode 100644 services/web/test/frontend/features/subscription/components/group-invite/managed-user-cannot-join.test.tsx
diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.js b/services/web/app/src/Features/Subscription/TeamInvitesController.js
index 3ec7388ef8..2a4179b4a3 100644
--- a/services/web/app/src/Features/Subscription/TeamInvitesController.js
+++ b/services/web/app/src/Features/Subscription/TeamInvitesController.js
@@ -14,6 +14,7 @@ const EmailHandler = require('../Email/EmailHandler')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const Modules = require('../../infrastructure/Modules')
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
+const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const rateLimiters = {
resendGroupInvite: new RateLimiter('resend-group-invite', {
@@ -72,6 +73,12 @@ async function viewInvite(req, res, next) {
return ErrorController.notFound(req, res)
}
+ const { variant } = await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'team-invite-react'
+ )
+
let validationStatus = new Map()
if (userId) {
const personalSubscription =
@@ -145,17 +152,30 @@ async function viewInvite(req, res, next) {
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 {
const userByEmail = await UserGetter.promises.getUserByMainEmail(
diff --git a/services/web/app/views/subscriptions/team/invite-react.pug b/services/web/app/views/subscriptions/team/invite-react.pug
new file mode 100644
index 0000000000..803de6a72d
--- /dev/null
+++ b/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
\ No newline at end of file
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 89ebfe98d7..1e46f737ea 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -119,6 +119,7 @@
"cancel_anytime": "",
"cancel_my_account": "",
"cancel_my_subscription": "",
+ "cancel_personal_subscription_first": "",
"cancel_your_subscription": "",
"cannot_invite_non_user": "",
"cannot_invite_self": "",
@@ -630,6 +631,7 @@
"join_now": "",
"join_project": "",
"join_team_explanation": "",
+ "joined_team": "",
"joining": "",
"justify": "",
"keep_current_plan": "",
@@ -1178,6 +1180,7 @@
"skip": "",
"something_not_right": "",
"something_went_wrong": "",
+ "something_went_wrong_canceling_your_subscription": "",
"something_went_wrong_loading_pdf_viewer": "",
"something_went_wrong_processing_the_request": "",
"something_went_wrong_rendering_pdf": "",
@@ -1559,6 +1562,7 @@
"you_can_now_enable_sso": "",
"you_can_now_log_in_sso": "",
"you_cant_add_or_change_password_due_to_sso": "",
+ "you_cant_join_this_group_subscription": "",
"you_dont_have_any_repositories": "",
"you_have_added_x_of_group_size_y": "",
"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_need_to_ask_the_github_repository_owner": "",
"youll_no_longer_need_to_remember_credentials": "",
+ "your_account_is_managed_by_admin_cant_join_additional_group": "",
"your_affiliation_is_confirmed": "",
"your_browser_does_not_support_this_feature": "",
"your_compile_timed_out": "",
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx
index 1c4a79835d..92a1a85254 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx
+++ b/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(() => {
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])
const dismissGroupInviteNotification = useCallback(() => {
diff --git a/services/web/frontend/js/features/subscription/components/group-invite/accepted-invite.tsx b/services/web/frontend/js/features/subscription/components/group-invite/accepted-invite.tsx
new file mode 100644
index 0000000000..2793d63d8b
--- /dev/null
+++ b/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 (
+
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx b/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx
new file mode 100644
index 0000000000..23637a849a
--- /dev/null
+++ b/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(undefined)
+
+ if (!view) {
+ return null
+ }
+
+ if (view === 'managed-user-cannot-join') {
+ return
+ } else if (view === 'cancel-personal-subscription') {
+ return
+ } else if (view === 'invite') {
+ return
+ } else if (view === 'invite-accepted') {
+ return
+ }
+
+ 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 (
+
+ {expired && (
+
+ )}
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/group-invite/has-individual-recurly-subscription.tsx b/services/web/frontend/js/features/subscription/components/group-invite/has-individual-recurly-subscription.tsx
new file mode 100644
index 0000000000..2a63c89575
--- /dev/null
+++ b/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>
+}) {
+ const { t } = useTranslation()
+ const {
+ runAsync,
+ isLoading: isCancelling,
+ isError,
+ } = useAsync()
+
+ const cancelPersonalSubscription = useCallback(() => {
+ runAsync(
+ postJSON('/user/subscription/cancel', {
+ body: {
+ _csrf: getMeta('ol-csrfToken'),
+ },
+ })
+ )
+ .then(() => {
+ setView('invite')
+ })
+ .catch(debugConsole.error)
+ }, [runAsync, setView])
+
+ return (
+ <>
+ {isError && (
+
+ )}
+
+
+
{t('cancel_personal_subscription_first')}
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/group-invite/join-group.tsx b/services/web/frontend/js/features/subscription/components/group-invite/join-group.tsx
new file mode 100644
index 0000000000..4f2c4a565b
--- /dev/null
+++ b/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>
+}) {
+ const { t } = useTranslation()
+ const expired = getMeta('ol-expired') as boolean
+ const inviteToken = getMeta('ol-inviteToken') as string
+ const {
+ runAsync,
+ isLoading: isJoining,
+ isError,
+ } = useAsync()
+
+ 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 && (
+
+ )}
+
+
+
{t('join_team_explanation')}
+ {!expired && (
+
+
+ {t('not_now')}
+
+
+
+
+ )}
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx b/services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx
new file mode 100644
index 0000000000..24f3bcd727
--- /dev/null
+++ b/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 (
+
+ ,
+ ]}
+ />
+
+ }
+ />
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/invite-root.tsx b/services/web/frontend/js/features/subscription/components/invite-root.tsx
new file mode 100644
index 0000000000..1034737e76
--- /dev/null
+++ b/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
+}
diff --git a/services/web/frontend/js/pages/user/subscription/invite.tsx b/services/web/frontend/js/pages/user/subscription/invite.tsx
new file mode 100644
index 0000000000..119f9e867f
--- /dev/null
+++ b/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(, element)
+}
diff --git a/services/web/frontend/stylesheets/app/subscription.less b/services/web/frontend/stylesheets/app/subscription.less
index 143d8de790..0965c6d74c 100644
--- a/services/web/frontend/stylesheets/app/subscription.less
+++ b/services/web/frontend/stylesheets/app/subscription.less
@@ -77,10 +77,8 @@
margin: 3em 0;
}
-.team-invite {
- .team-invite-name {
- word-break: break-word;
- }
+.team-invite-name {
+ word-break: break-word;
}
.capitalised {
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx
new file mode 100644
index 0000000000..68826396a3
--- /dev/null
+++ b/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()
+ 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()
+ 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()
+ const linkBtn = (await screen.findByRole('link', {
+ name: 'Done',
+ })) as HTMLLinkElement
+ expect(linkBtn.href).to.equal('https://www.test-overleaf.com/project')
+ })
+})
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx
new file mode 100644
index 0000000000..155e0ba2a4
--- /dev/null
+++ b/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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ await screen.findByText(
+ 'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
+ )
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx
new file mode 100644
index 0000000000..712890f45e
--- /dev/null
+++ b/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( {}} />)
+ 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()
+ 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( {}} />)
+ 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.'
+ )
+ })
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx
new file mode 100644
index 0000000000..e297d30ac6
--- /dev/null
+++ b/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( {}} />)
+ 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()
+ 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( {}} />)
+ const button = await screen.getByRole('button', {
+ name: 'Accept invitation',
+ })
+ fireEvent.click(button)
+ await waitFor(() => {
+ screen.getByText('Sorry, something went wrong')
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/managed-user-cannot-join.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/managed-user-cannot-join.test.tsx
new file mode 100644
index 0000000000..bb1001feb4
--- /dev/null
+++ b/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()
+ 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.' })
+ })
+})
diff --git a/services/web/test/unit/src/Subscription/TeamInvitesControllerTests.js b/services/web/test/unit/src/Subscription/TeamInvitesControllerTests.js
index a65560133f..1506492d96 100644
--- a/services/web/test/unit/src/Subscription/TeamInvitesControllerTests.js
+++ b/services/web/test/unit/src/Subscription/TeamInvitesControllerTests.js
@@ -1,5 +1,6 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
+const { expect } = require('chai')
const modulePath =
'../../../../app/src/Features/Subscription/TeamInvitesController'
@@ -26,19 +27,30 @@ describe('TeamInvitesController', function () {
}
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 = {
promises: {
hasSSOEnabled: sinon.stub().resolves(true),
+ getUsersSubscription: sinon.stub().resolves(),
},
}
this.ErrorController = { notFound: sinon.stub() }
this.SessionManager = {
getLoggedInUserId(session) {
- return session.user._id
+ return session.user?._id
},
getSessionUser(session) {
return session.user
@@ -75,6 +87,18 @@ describe('TeamInvitesController', function () {
'../User/UserGetter': this.UserGetter,
'../Email/EmailHandler': this.EmailHandler,
'../../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)
})
})
+
+ 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)
+ })
+ })
+ })
+ })
})