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 ( +
+

{t('joined_team', { inviterName })}

+

+ + {t('done')} + +

+
+ ) +} 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) + }) + }) + }) + }) })