Merge pull request #13543 from overleaf/mf-enhance-group-invitation-ux

Show dashboard notification to registered users after being invited to join a group subscription

GitOrigin-RevId: ad03dfea95f0d5d1a38780adc3e9d618eae0a48d
This commit is contained in:
M Fahru 2023-09-25 10:58:23 -07:00 committed by Copybot
parent 27bdf9fb0b
commit 8111ff2865
22 changed files with 986 additions and 62 deletions

View file

@ -224,6 +224,37 @@ function tpdsFileLimit(userId) {
}
}
function groupInvitation(userId, subscriptionId, managedUsersEnabled) {
return {
key: `groupInvitation-${subscriptionId}-${userId}`,
create(invite, callback) {
if (callback == null) {
callback = function () {}
}
const messageOpts = {
token: invite.token,
inviterName: invite.inviterName,
managedUsersEnabled,
}
NotificationsHandler.createNotification(
userId,
this.key,
'notification_group_invitation',
messageOpts,
null,
true,
callback
)
},
read(callback) {
if (callback == null) {
callback = function () {}
}
NotificationsHandler.markAsReadByKeyOnly(this.key, callback)
},
}
}
const NotificationsBuilder = {
// Note: notification keys should be url-safe
dropboxUnlinkedDueToLapsedReconfirmation,
@ -233,6 +264,7 @@ const NotificationsBuilder = {
projectInvite,
ipMatcherAffiliation,
tpdsFileLimit,
groupInvitation,
}
NotificationsBuilder.promises = {
@ -248,6 +280,9 @@ NotificationsBuilder.promises = {
ipMatcherAffiliation: function (userId) {
return promisifyAll(ipMatcherAffiliation(userId))
},
groupInvitation: function (userId, groupId, managedUsersEnabled) {
return promisifyAll(groupInvitation(userId, groupId, managedUsersEnabled))
},
}
module.exports = NotificationsBuilder

View file

@ -23,6 +23,7 @@ const LimitationsManager = require('../Subscription/LimitationsManager')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
/** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */
/** @typedef {import("./types").GetProjectsResponse} GetProjectsResponse */
@ -432,6 +433,20 @@ async function projectListPage(req, res, next) {
}
}
let hasIndividualRecurlySubscription = false
try {
const individualSubscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
hasIndividualRecurlySubscription =
individualSubscription?.groupPlan === false &&
individualSubscription?.recurlyStatus?.state !== 'canceled' &&
individualSubscription?.recurlySubscription_id !== ''
} catch (error) {
logger.error({ err: error }, 'Failed to get individual subscription')
}
res.render('project/list-react', {
title: 'your_projects',
usersBestSubscription,
@ -462,6 +477,7 @@ async function projectListPage(req, res, next) {
groupId: subscription._id,
groupName: subscription.teamName,
})),
hasIndividualRecurlySubscription,
})
}

View file

@ -17,6 +17,7 @@ const EmailHelper = require('../Helpers/EmailHelper')
const Errors = require('../Errors/Errors')
const { callbackify, callbackifyMultiResult } = require('../../util/promises')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
async function getInvite(token) {
const subscription = await Subscription.findOne({
@ -75,14 +76,29 @@ async function acceptInvite(token, userId) {
}
await _removeInviteFromTeam(subscription.id, invite.email)
await NotificationsBuilder.promises
.groupInvitation(userId, subscription._id, false)
.read()
}
async function revokeInvite(teamManagerId, subscription, email) {
email = EmailHelper.parseEmail(email)
if (!email) {
throw new Error('invalid email')
}
await _removeInviteFromTeam(subscription.id, email)
// Remove group invitation dashboard notification if invitation is revoked before
// the invited user accepted the group invitation
const user = await UserGetter.promises.getUserByAnyEmail(email)
if (user) {
await NotificationsBuilder.promises
.groupInvitation(user._id, subscription._id, false)
.read()
}
}
// Legacy method to allow a user to receive a confirmation email if their
@ -92,7 +108,6 @@ async function createTeamInvitesForLegacyInvitedEmail(email) {
const teams = await SubscriptionLocator.promises.getGroupsWithEmailInvite(
email
)
return Promise.all(
teams.map(team => createInvite(team.admin_id, team, email))
)
@ -145,6 +160,21 @@ async function _createInvite(subscription, email, inviter) {
subscription.teamInvites.push(invite)
}
try {
const managedUsersEnabled = Boolean(subscription.groupPolicy)
await _sendNotificationToExistingUser(
subscription,
email,
invite,
managedUsersEnabled
)
} catch (err) {
logger.error(
{ err },
'Failed to send notification to existing user when creating group invitation'
)
}
await subscription.save()
if (subscription.groupPolicy) {
@ -201,6 +231,27 @@ async function _removeInviteFromTeam(subscriptionId, email, callback) {
await _removeLegacyInvite(subscriptionId, email)
}
async function _sendNotificationToExistingUser(
subscription,
email,
invite,
managedUsersEnabled
) {
const user = await UserGetter.promises.getUserByMainEmail(email)
if (!user) {
return
}
await NotificationsBuilder.promises
.groupInvitation(
user._id.toString(),
subscription._id.toString(),
managedUsersEnabled
)
.create(invite)
}
async function _removeLegacyInvite(subscriptionId, email) {
await Subscription.updateOne(
{

View file

@ -36,6 +36,7 @@ block append meta
meta(name="ol-showBackToSchoolModal" data-type="boolean" content=showBackToSchoolModal)
meta(name="ol-welcomePageRedesignVariant" data-type="string" content=welcomePageRedesignVariant)
meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment)
meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription)
block content
main.content.content-alt.project-list-react#project-list-root

View file

@ -113,6 +113,7 @@
"cancel": "",
"cancel_anytime": "",
"cancel_my_account": "",
"cancel_my_subscription": "",
"cancel_your_subscription": "",
"cannot_invite_non_user": "",
"cannot_invite_self": "",
@ -193,6 +194,7 @@
"confirm_primary_email_change": "",
"confirming": "",
"conflicting_paths_found": "",
"congratulations_youve_successfully_join_group": "",
"connected_users": "",
"contact_group_admin": "",
"contact_message_label": "",
@ -543,8 +545,11 @@
"invalid_request": "",
"invite_more_collabs": "",
"invite_not_accepted": "",
"invited_to_group": "",
"invited_to_group_have_individual_subcription": "",
"ip_address": "",
"is_email_affiliated": "",
"join_now": "",
"join_project": "",
"joining": "",
"keep_current_plan": "",
@ -720,6 +725,7 @@
"normally_x_price_per_month": "",
"normally_x_price_per_year": "",
"not_managed": "",
"not_now": "",
"notification_project_invite_accepted_message": "",
"notification_project_invite_message": "",
"number_of_users": "",

View file

@ -7,8 +7,12 @@ import useAsyncDismiss from '../hooks/useAsyncDismiss'
import useAsync from '../../../../../shared/hooks/use-async'
import { FetchError, postJSON } from '../../../../../infrastructure/fetch-json'
import { ExposedSettings } from '../../../../../../../types/exposed-settings'
import { Notification as NotificationType } from '../../../../../../../types/project/dashboard/notification'
import {
NotificationProjectInvite,
Notification as NotificationType,
} from '../../../../../../../types/project/dashboard/notification'
import { User } from '../../../../../../../types/user'
import GroupInvitationNotification from './group-invitation/group-invitation'
function Common() {
const notifications = getMeta('ol-notifications', []) as NotificationType[]
@ -42,16 +46,17 @@ function CommonNotification({ notification }: CommonNotificationProps) {
// 404 probably means the invite has already been accepted and deleted. Treat as success
const accepted = isSuccess || error?.response?.status === 404
function handleAcceptInvite() {
function handleAcceptInvite(notification: NotificationProjectInvite) {
const {
messageOpts: { projectId, token },
} = notification
runAsync(
postJSON(`/project/${projectId}/invite/token/${token}/accept`)
).catch(console.error)
}
const { _id: id, templateKey, messageOpts, html } = notification
const { _id: id, templateKey, html } = notification
return (
<>
@ -62,15 +67,15 @@ function CommonNotification({ notification }: CommonNotificationProps) {
<Trans
i18nKey="notification_project_invite_accepted_message"
components={{ b: <b /> }}
values={{ projectName: messageOpts.projectName }}
values={{ projectName: notification.messageOpts.projectName }}
/>
) : (
<Trans
i18nKey="notification_project_invite_message"
components={{ b: <b /> }}
values={{
userName: messageOpts.userName,
projectName: messageOpts.projectName,
userName: notification.messageOpts.userName,
projectName: notification.messageOpts.projectName,
}}
/>
)}
@ -81,7 +86,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
bsStyle="info"
bsSize="sm"
className="pull-right"
href={`/project/${messageOpts.projectId}`}
href={`/project/${notification.messageOpts.projectId}`}
>
{t('open_project')}
</Button>
@ -90,7 +95,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
bsStyle="info"
bsSize="sm"
disabled={isLoading}
onClick={handleAcceptInvite}
onClick={() => handleAcceptInvite(notification)}
>
{isLoading ? (
<>
@ -128,11 +133,11 @@ function CommonNotification({ notification }: CommonNotificationProps) {
i18nKey="looks_like_youre_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{
institutionName: messageOpts.university_name,
institutionName: notification.messageOpts.university_name,
}}
/>
<br />
{messageOpts.ssoEnabled ? (
{notification.messageOpts.ssoEnabled ? (
<>
<Trans
i18nKey="you_can_now_log_in_sso"
@ -142,7 +147,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
{t('link_institutional_email_get_started')}{' '}
<a
href={
messageOpts.portalPath ||
notification.messageOpts.portalPath ||
'https://www.overleaf.com/learn/how-to/Institutional_Login'
}
>
@ -155,7 +160,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
i18nKey="did_you_know_institution_providing_professional"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{
institutionName: messageOpts.university_name,
institutionName: notification.messageOpts.university_name,
}}
/>
<br />
@ -169,12 +174,12 @@ function CommonNotification({ notification }: CommonNotificationProps) {
bsSize="sm"
className="pull-right"
href={
messageOpts.ssoEnabled
? `${samlInitPath}?university_id=${messageOpts.institutionId}&auto=/project`
notification.messageOpts.ssoEnabled
? `${samlInitPath}?university_id=${notification.messageOpts.institutionId}&auto=/project`
: '/user/settings'
}
>
{messageOpts.ssoEnabled
{notification.messageOpts.ssoEnabled
? t('link_account')
: t('add_affiliation')}
</Button>
@ -186,8 +191,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
Error: Your project {messageOpts.projectName} has gone over the 2000
file limit using an integration (e.g. Dropbox or GitHub) <br />
Error: Your project {notification.messageOpts.projectName} has gone
over the 2000 file limit using an integration (e.g. Dropbox or
GitHub) <br />
Please decrease the size of your project to prevent further errors.
</Notification.Body>
<Notification.Action>
@ -211,7 +217,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
<Trans
i18nKey="dropbox_duplicate_project_names"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ projectName: messageOpts.projectName }}
values={{ projectName: notification.messageOpts.projectName }}
/>
</p>
<p>
@ -248,6 +254,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
</a>
</Notification.Body>
</Notification>
) : templateKey === 'notification_group_invitation' ? (
<GroupInvitationNotification notification={notification} />
) : (
<Notification bsStyle="info" onDismiss={() => id && handleDismiss(id)}>
<Notification.Body>{html}</Notification.Body>

View file

@ -0,0 +1,53 @@
import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import type { Dispatch, SetStateAction } from 'react'
import Notification from '../../notification'
import { GroupInvitationStatus } from './hooks/use-group-invitation-notification'
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
type GroupInvitationCancelIndividualSubscriptionNotificationProps = {
setGroupInvitationStatus: Dispatch<SetStateAction<GroupInvitationStatus>>
cancelPersonalSubscription: () => void
dismissGroupInviteNotification: () => void
notification: NotificationGroupInvitation
}
export default function GroupInvitationCancelIndividualSubscriptionNotification({
setGroupInvitationStatus,
cancelPersonalSubscription,
dismissGroupInviteNotification,
notification,
}: GroupInvitationCancelIndividualSubscriptionNotificationProps) {
const { t } = useTranslation()
const {
messageOpts: { inviterName },
} = notification
return (
<Notification bsStyle="info" onDismiss={dismissGroupInviteNotification}>
<Notification.Body>
<Trans
i18nKey="invited_to_group_have_individual_subcription"
values={{
inviterName,
}}
/>
</Notification.Body>
<Notification.Action className="group-invitation-cancel-subscription-notification-buttons">
<Button
bsStyle="info"
bsSize="sm"
className="me-1"
onClick={() =>
setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
}
>
{t('not_now')}
</Button>
<Button bsStyle="info" bsSize="sm" onClick={cancelPersonalSubscription}>
{t('cancel_my_subscription')}
</Button>
</Notification.Action>
</Notification>
)
}

View file

@ -0,0 +1,47 @@
import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import Notification from '../../notification'
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
type GroupInvitationNotificationProps = {
acceptGroupInvite: () => void
notification: NotificationGroupInvitation
isAcceptingInvitation: boolean
dismissGroupInviteNotification: () => void
}
export default function GroupInvitationNotificationJoin({
acceptGroupInvite,
notification,
isAcceptingInvitation,
dismissGroupInviteNotification,
}: GroupInvitationNotificationProps) {
const { t } = useTranslation()
const {
messageOpts: { inviterName },
} = notification
return (
<Notification bsStyle="info" onDismiss={dismissGroupInviteNotification}>
<Notification.Body>
<Trans
i18nKey="invited_to_group"
values={{
inviterName,
}}
/>
</Notification.Body>
<Notification.Action>
<Button
bsStyle="info"
bsSize="sm"
className="pull-right"
onClick={acceptGroupInvite}
disabled={isAcceptingInvitation}
>
{t('join_now')}
</Button>
</Notification.Action>
</Notification>
)
}

View file

@ -0,0 +1,22 @@
import Notification from '../../notification'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../../../shared/components/icon'
type GroupInvitationSuccessfulNotificationProps = {
hideNotification: () => void
}
export default function GroupInvitationSuccessfulNotification({
hideNotification,
}: GroupInvitationSuccessfulNotificationProps) {
const { t } = useTranslation()
return (
<Notification bsStyle="success" onDismiss={hideNotification}>
<Notification.Body>
<Icon type="check-circle" fw aria-hidden="true" className="me-1" />
{t('congratulations_youve_successfully_join_group')}
</Notification.Body>
</Notification>
)
}

View file

@ -0,0 +1,55 @@
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
import GroupInvitationCancelIndividualSubscriptionNotification from './group-invitation-cancel-subscription'
import GroupInvitationNotificationJoin from './group-invitation-join'
import GroupInvitationSuccessfulNotification from './group-invitation-successful'
import {
GroupInvitationStatus,
useGroupInvitationNotification,
} from './hooks/use-group-invitation-notification'
type GroupInvitationNotificationProps = {
notification: NotificationGroupInvitation
}
export default function GroupInvitationNotification({
notification,
}: GroupInvitationNotificationProps) {
const {
isAcceptingInvitation,
groupInvitationStatus,
setGroupInvitationStatus,
acceptGroupInvite,
cancelPersonalSubscription,
dismissGroupInviteNotification,
hideNotification,
} = useGroupInvitationNotification(notification)
switch (groupInvitationStatus) {
case GroupInvitationStatus.CancelIndividualSubscription:
return (
<GroupInvitationCancelIndividualSubscriptionNotification
setGroupInvitationStatus={setGroupInvitationStatus}
cancelPersonalSubscription={cancelPersonalSubscription}
dismissGroupInviteNotification={dismissGroupInviteNotification}
notification={notification}
/>
)
case GroupInvitationStatus.AskToJoin:
return (
<GroupInvitationNotificationJoin
isAcceptingInvitation={isAcceptingInvitation}
notification={notification}
acceptGroupInvite={acceptGroupInvite}
dismissGroupInviteNotification={dismissGroupInviteNotification}
/>
)
case GroupInvitationStatus.SuccessfullyJoined:
return (
<GroupInvitationSuccessfulNotification
hideNotification={hideNotification}
/>
)
default:
return null
}
}

View file

@ -0,0 +1,132 @@
import {
type Dispatch,
type SetStateAction,
useState,
useCallback,
useEffect,
} from 'react'
import type { NotificationGroupInvitation } from '../../../../../../../../../types/project/dashboard/notification'
import useAsync from '../../../../../../../shared/hooks/use-async'
import {
FetchError,
postJSON,
putJSON,
} from '../../../../../../../infrastructure/fetch-json'
import { useLocation } from '../../../../../../../shared/hooks/use-location'
import getMeta from '../../../../../../../utils/meta'
import useAsyncDismiss from '../../../hooks/useAsyncDismiss'
const SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN = 10 * 1000
/* eslint-disable no-unused-vars */
export enum GroupInvitationStatus {
Idle = 'Idle',
CancelIndividualSubscription = 'CancelIndividualSubscription',
AskToJoin = 'AskToJoin',
SuccessfullyJoined = 'SuccessfullyJoined',
NotificationIsHidden = 'NotificationIsHidden',
Error = 'Error',
}
/* eslint-enable no-unused-vars */
type UseGroupInvitationNotificationReturnType = {
isAcceptingInvitation: boolean
groupInvitationStatus: GroupInvitationStatus
setGroupInvitationStatus: Dispatch<SetStateAction<GroupInvitationStatus>>
acceptGroupInvite: () => void
cancelPersonalSubscription: () => void
dismissGroupInviteNotification: () => void
hideNotification: () => void
}
export function useGroupInvitationNotification(
notification: NotificationGroupInvitation
): UseGroupInvitationNotificationReturnType {
const {
_id: notificationId,
messageOpts: { token, managedUsersEnabled },
} = notification
const [groupInvitationStatus, setGroupInvitationStatus] =
useState<GroupInvitationStatus>(GroupInvitationStatus.Idle)
const { runAsync, isLoading: isAcceptingInvitation } = useAsync<
never,
FetchError
>()
const location = useLocation()
const { handleDismiss } = useAsyncDismiss()
const hasIndividualRecurlySubscription = getMeta(
'ol-hasIndividualRecurlySubscription'
) as boolean | undefined
useEffect(() => {
if (hasIndividualRecurlySubscription) {
setGroupInvitationStatus(
GroupInvitationStatus.CancelIndividualSubscription
)
} else {
setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
}
}, [hasIndividualRecurlySubscription])
const acceptGroupInvite = useCallback(() => {
if (managedUsersEnabled) {
location.assign(`/subscription/invites/${token}/`)
} else {
runAsync(
putJSON(`/subscription/invites/${token}/`, {
body: {
_csrf: getMeta('ol-csrfToken'),
},
})
)
.then(() => {
setGroupInvitationStatus(GroupInvitationStatus.SuccessfullyJoined)
})
.catch(err => {
setGroupInvitationStatus(GroupInvitationStatus.Error)
console.error(err)
})
.finally(() => {
// remove notification automatically in the browser
window.setTimeout(() => {
setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden)
}, SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN)
})
}
}, [runAsync, token, location, managedUsersEnabled])
const cancelPersonalSubscription = useCallback(() => {
setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
runAsync(
postJSON('/user/subscription/cancel', {
body: {
_csrf: getMeta('ol-csrfToken'),
},
})
).catch(console.error)
}, [runAsync])
const dismissGroupInviteNotification = useCallback(() => {
if (notificationId) {
handleDismiss(notificationId)
}
}, [handleDismiss, notificationId])
const hideNotification = useCallback(() => {
setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden)
}, [])
return {
isAcceptingInvitation,
groupInvitationStatus,
setGroupInvitationStatus,
acceptGroupInvite,
cancelPersonalSubscription,
dismissGroupInviteNotification,
hideNotification,
}
}

View file

@ -31,18 +31,6 @@ export const fakeReconfirmationUsersData = {
default: false,
} as DeepReadonly<UserEmailData>
const fakeNotificationData = {
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
ssoEnabled: false,
institutionId: '456',
userName: 'fakeUser',
university_name: 'Abc University',
token: 'abcdef',
},
} as DeepReadonly<Notification>
export function defaultSetupMocks(fetchMock: FetchMockStatic) {
// at least one project is required to show some notifications
const projects = [{}] as Project[]
@ -94,9 +82,7 @@ export function institutionSetupMocks(fetchMock: FetchMockStatic) {
export function setCommonMeta(notificationData: DeepPartial<Notification>) {
setDefaultMeta()
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(fakeNotificationData), notificationData),
])
window.metaAttributesCache.set('ol-notifications', [notificationData])
}
export function commonSetupMocks(fetchMock: FetchMockStatic) {

View file

@ -18,6 +18,12 @@ export const ProjectInvite = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
templateKey: 'notification_project_invite',
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
userName: 'fakeUser',
token: 'abcdef',
},
})
return (
@ -31,6 +37,12 @@ export const ProjectInviteNetworkError = (args: any) => {
useFetchMock(errorsMocks)
setCommonMeta({
templateKey: 'notification_project_invite',
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
userName: 'fakeUser',
token: 'abcdef',
},
})
return (
@ -60,6 +72,8 @@ export const IPMatchedAffiliationSsoEnabled = (args: any) => {
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: {
university_name: 'Abc University',
institutionId: '456',
ssoEnabled: true,
},
})
@ -77,6 +91,8 @@ export const IPMatchedAffiliationSsoDisabled = (args: any) => {
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: {
university_name: 'Abc University',
institutionId: '456',
ssoEnabled: false,
},
})
@ -93,6 +109,9 @@ export const TpdsFileLimit = (args: any) => {
setCommonMeta({
_id: 1,
templateKey: 'notification_tpds_file_limit',
messageOpts: {
projectName: 'Abc Project',
},
})
return (
@ -107,6 +126,9 @@ export const DropBoxDuplicateProjectNames = (args: any) => {
setCommonMeta({
_id: 1,
templateKey: 'notification_dropbox_duplicate_project_names',
messageOpts: {
projectName: 'Abc Project',
},
})
return (
@ -130,6 +152,42 @@ export const DropBoxUnlinkedDueToLapsedReconfirmation = (args: any) => {
)
}
export const NotificationGroupInvitation = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_group_invitation',
messageOpts: {
inviterName: 'John Doe',
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const NotificationGroupInvitationCancelSubscription = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_group_invitation',
messageOpts: {
inviterName: 'John Doe',
},
})
window.metaAttributesCache.set('ol-hasIndividualRecurlySubscription', true)
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const NonSpecificMessage = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({ _id: 1, html: 'Non specific message' })

View file

@ -194,6 +194,11 @@
}
}
.group-invitation-cancel-subscription-notification-buttons {
display: flex;
align-items: center;
}
// Settings page
.affiliations-table {
.reconfirm-notification {

View file

@ -199,6 +199,7 @@
"cancel": "Cancel",
"cancel_anytime": "Were confident that youll love __appName__, but if not you can cancel anytime. Well give you your money back, no questions asked, if you let us know within 30 days.",
"cancel_my_account": "Cancel my subscription",
"cancel_my_subscription": "Cancel my subscription",
"cancel_personal_subscription_first": "You already have an individual subscription, would you like us to cancel this first before joining the group licence?",
"cancel_your_subscription": "Cancel Your Subscription",
"cannot_invite_non_user": "Cant send invite. Recipient must already have an __appName__ account",
@ -309,6 +310,7 @@
"confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
"confirming": "Confirming",
"conflicting_paths_found": "Conflicting Paths Found",
"congratulations_youve_successfully_join_group": "Congratulations! Youve successfully joined the group subscription.",
"connected_users": "Connected Users",
"connecting": "Connecting",
"contact": "Contact",
@ -863,6 +865,7 @@
"invite_not_valid": "This is not a valid project invite",
"invite_not_valid_description": "The invite may have expired. Please contact the project owner",
"invited_to_group": "__inviterName__ has invited you to join a group subscription on __appName__",
"invited_to_group_have_individual_subcription": "__inviterName__ has invited you to join a group __appName__ subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?",
"invited_to_group_login": "To accept this invitation you need to log in as __emailAddress__.",
"invited_to_group_login_benefits": "As part of this group, youll have access to __appName__ premium features such as additional collaborators, greater maximum compile time, and real-time track changes.",
"invited_to_group_register": "To accept __inviterName__s invitation youll need to create an account.",
@ -876,6 +879,7 @@
"ja": "Japanese",
"january": "January",
"join_beta_program": "Join beta program",
"join_now": "Join now",
"join_project": "Join Project",
"join_sl_to_view_project": "Join __appName__ to view this project",
"join_team_explanation": "Please click the button below to join the group subscription and enjoy the benefits of an upgraded __appName__ account",

View file

@ -0,0 +1,133 @@
import GroupInvitationNotification from '@/features/project-list/components/notifications/groups/group-invitation/group-invitation'
import { NotificationGroupInvitation } from '../../../../../types/project/dashboard/notification'
type Props = {
notification: NotificationGroupInvitation
}
function GroupInvitation({ notification }: Props) {
return (
<div className="user-notifications">
<ul className="list-unstyled">
<GroupInvitationNotification notification={notification} />
</ul>
</div>
)
}
describe('<GroupInvitationNotification />', function () {
const notification: NotificationGroupInvitation = {
_id: 1,
templateKey: 'notification_group_invitation',
messageOpts: {
inviterName: 'inviter@overleaf.com',
token: '123abc',
managedUsersEnabled: false,
},
}
beforeEach(function () {
cy.intercept(
'PUT',
`/subscription/invites/${notification.messageOpts.token}`,
{
statusCode: 204,
}
).as('acceptInvite')
})
describe('user without existing personal subscription', function () {
it('is able to join group successfully', function () {
cy.mount(<GroupInvitation notification={notification} />)
cy.findByRole('alert')
cy.findByText(
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
)
cy.findByRole('button', { name: 'Join now' }).click()
cy.wait('@acceptInvite')
cy.findByText(
'Congratulations! Youve successfully joined the group subscription.'
)
cy.findByRole('button', { name: /close/i }).click()
cy.findByRole('alert').should('not.exist')
})
})
describe('user with existing personal subscription', function () {
beforeEach(function () {
window.metaAttributesCache.set(
'ol-hasIndividualRecurlySubscription',
true
)
})
it('is able to join group successfully without cancelling personal subscription', function () {
cy.mount(<GroupInvitation notification={notification} />)
cy.findByRole('alert')
cy.findByText(
'inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?'
)
cy.findByRole('button', { name: 'Not now' }).click()
cy.findByText(
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
)
cy.findByRole('button', { name: 'Join now' }).click()
cy.wait('@acceptInvite')
cy.findByText(
'Congratulations! Youve successfully joined the group subscription.'
)
cy.findByRole('button', { name: /close/i }).click()
cy.findByRole('alert').should('not.exist')
})
it('is able to join group successfully after cancelling personal subscription', function () {
cy.intercept('POST', '/user/subscription/cancel', {
statusCode: 204,
}).as('cancelPersonalSubscription')
cy.mount(<GroupInvitation notification={notification} />)
cy.findByRole('alert')
cy.findByText(
'inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?'
)
cy.findByRole('button', { name: 'Cancel my subscription' }).click()
cy.wait('@cancelPersonalSubscription')
cy.findByText(
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
)
cy.findByRole('button', { name: 'Join now' }).click()
cy.wait('@acceptInvite')
cy.findByText(
'Congratulations! Youve successfully joined the group subscription.'
)
cy.findByRole('button', { name: /close/i }).click()
cy.findByRole('alert').should('not.exist')
})
})
})

View file

@ -15,7 +15,11 @@ import {
unconfirmedCommonsUserData,
} from '../../settings/fixtures/test-user-email-data'
import {
notification,
notificationDropboxDuplicateProjectNames,
notificationGroupInviteDefault,
notificationIPMatchedAffiliation,
notificationProjectInvite,
notificationTPDSFileLimit,
notificationsInstitution,
} from '../fixtures/notifications-data'
import Common from '../../../../../frontend/js/features/project-list/components/notifications/groups/common'
@ -86,7 +90,7 @@ describe('<UserNotifications />', function () {
templateKey: 'notification_project_invite',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(cloneDeep(notificationProjectInvite), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
@ -97,7 +101,7 @@ describe('<UserNotifications />', function () {
200
)
const acceptMock = fetchMock.post(
`project/${notification.messageOpts.projectId}/invite/token/${notification.messageOpts.token}/accept`,
`project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
200
)
@ -124,7 +128,7 @@ describe('<UserNotifications />', function () {
const openProject = screen.getByRole('link', { name: /open project/i })
expect(openProject.getAttribute('href')).to.equal(
`/project/${notification.messageOpts.projectId}`
`/project/${notificationProjectInvite.messageOpts.projectId}`
)
const closeBtn = screen.getByRole('button', { name: /close/i })
@ -140,13 +144,13 @@ describe('<UserNotifications />', function () {
templateKey: 'notification_project_invite',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(cloneDeep(notificationProjectInvite), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.post(
`project/${notification.messageOpts.projectId}/invite/token/${notification.messageOpts.token}/accept`,
`project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
500
)
@ -174,7 +178,7 @@ describe('<UserNotifications />', function () {
templateKey: 'wfh_2020_upgrade_offer',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
@ -202,7 +206,10 @@ describe('<UserNotifications />', function () {
messageOpts: { ssoEnabled: true },
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(
cloneDeep(notificationIPMatchedAffiliation),
reconfiguredNotification
),
])
renderWithinProjectListProvider(Common)
@ -222,7 +229,7 @@ describe('<UserNotifications />', function () {
)
const linkAccount = screen.getByRole('link', { name: /link account/i })
expect(linkAccount.getAttribute('href')).to.equal(
`${exposedSettings.samlInitPath}?university_id=${notification.messageOpts.institutionId}&auto=/project`
`${exposedSettings.samlInitPath}?university_id=${notificationIPMatchedAffiliation.messageOpts.institutionId}&auto=/project`
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
@ -238,7 +245,10 @@ describe('<UserNotifications />', function () {
messageOpts: { ssoEnabled: false },
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(
cloneDeep(notificationIPMatchedAffiliation),
reconfiguredNotification
),
])
renderWithinProjectListProvider(Common)
@ -268,7 +278,7 @@ describe('<UserNotifications />', function () {
templateKey: 'notification_tpds_file_limit',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(cloneDeep(notificationTPDSFileLimit), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
@ -296,7 +306,10 @@ describe('<UserNotifications />', function () {
templateKey: 'notification_dropbox_duplicate_project_names',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(
cloneDeep(notificationDropboxDuplicateProjectNames),
reconfiguredNotification
),
])
renderWithinProjectListProvider(Common)
@ -325,7 +338,10 @@ describe('<UserNotifications />', function () {
'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
merge(
cloneDeep(notificationDropboxDuplicateProjectNames),
reconfiguredNotification
),
])
renderWithinProjectListProvider(Common)
@ -355,7 +371,7 @@ describe('<UserNotifications />', function () {
html: 'unspecific message',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
reconfiguredNotification,
])
renderWithinProjectListProvider(Common)
@ -371,6 +387,68 @@ describe('<UserNotifications />', function () {
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
describe('<GroupInvitation />', function () {
describe('without existing personal subscription', function () {
it('shows group invitation notification for user without personal subscription', async function () {
const notificationGroupInvite: DeepPartial<Notification> = {
_id: 1,
templateKey: 'notification_group_invitation',
}
window.metaAttributesCache.set('ol-notifications', [
merge(
cloneDeep(notificationGroupInviteDefault),
notificationGroupInvite
),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(`/notifications/${notificationGroupInvite._id}`, 200)
screen.getByRole('alert')
screen.getByText(
/inviter@overleaf.com has invited you to join a group subscription on Overleaf/
)
screen.getByRole('button', { name: 'Join now' })
screen.getByRole('button', { name: /close/i })
})
describe('with existing personal subscription', function () {
it('shows group invitation notification for user with personal subscription', async function () {
const notificationGroupInvite: DeepPartial<Notification> = {
_id: 1,
templateKey: 'notification_group_invitation',
}
window.metaAttributesCache.set('ol-notifications', [
merge(
cloneDeep(notificationGroupInviteDefault),
notificationGroupInvite
),
])
window.metaAttributesCache.set(
'ol-hasIndividualRecurlySubscription',
true
)
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(
`/notifications/${notificationGroupInvite._id}`,
200
)
screen.getByRole('alert')
screen.getByText(
/inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it/
)
screen.getByRole('button', { name: 'Not now' })
screen.getByRole('button', { name: 'Cancel my subscription' })
})
})
})
})
})
describe('<Institution>', function () {

View file

@ -1,7 +1,11 @@
import { DeepReadonly } from '../../../../../types/utils'
import {
Institution,
Notification,
NotificationDropboxDuplicateProjectNames,
NotificationGroupInvitation,
NotificationIPMatchedAffiliation,
NotificationProjectInvite,
NotificationTPDSFileLimit,
} from '../../../../../types/project/dashboard/notification'
export const notificationsInstitution = {
@ -12,14 +16,47 @@ export const notificationsInstitution = {
requestedEmail: 'requested@example.com',
} as DeepReadonly<Institution>
export const notification = {
export const notificationProjectInvite = {
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
ssoEnabled: false,
institutionId: '456',
userName: 'fakeUser',
university_name: 'Abc University',
token: 'abcdef',
},
} as DeepReadonly<Notification>
} as DeepReadonly<NotificationProjectInvite>
export const notificationIPMatchedAffiliation = {
messageOpts: {
university_name: 'Abc University',
ssoEnabled: false,
institutionId: '456',
},
} as DeepReadonly<NotificationIPMatchedAffiliation>
export const notificationTPDSFileLimit = {
messageOpts: {
projectName: 'Abc Project',
},
} as DeepReadonly<NotificationTPDSFileLimit>
export const notificationDropboxDuplicateProjectNames = {
messageOpts: {
projectName: 'Abc Project',
},
} as DeepReadonly<NotificationDropboxDuplicateProjectNames>
export const notificationGroupInviteDefault = {
messageOpts: {
token: '123abc',
inviterName: 'inviter@overleaf.com',
managedUsersEnabled: false,
},
} as DeepReadonly<NotificationGroupInvitation>
export const notificationGroupInviteManagedUsers = {
messageOpts: {
token: '123abc',
inviterName: 'inviter@overleaf.com',
managedUsersEnabled: true,
},
} as DeepReadonly<NotificationGroupInvitation>

View file

@ -57,6 +57,42 @@ describe('NotificationsBuilder', function () {
})
})
describe('groupInvitation', function (done) {
const subscriptionId = '123123bcabca'
beforeEach(function () {
this.invite = {
token: '123123abcabc',
inviterName: 'Mr Overleaf',
managedUsersEnabled: false,
}
})
it('should create the notification', function (done) {
this.controller
.groupInvitation(
userId,
subscriptionId,
this.invite.managedUsersEnabled
)
.create(this.invite, error => {
expect(error).to.not.exist
expect(this.handler.createNotification).to.have.been.calledWith(
userId,
`groupInvitation-${subscriptionId}-${userId}`,
'notification_group_invitation',
{
token: this.invite.token,
inviterName: this.invite.inviterName,
managedUsersEnabled: this.invite.managedUsersEnabled,
},
null,
true
)
done()
})
})
})
describe('ipMatcherAffiliation', function () {
describe('with portal and with SSO', function () {
beforeEach(function () {

View file

@ -121,6 +121,11 @@ describe('ProjectListController', function () {
ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }),
},
}
this.SubscriptionLocator = {
promises: {
getUserSubscription: sinon.stub().resolves({}),
},
}
this.ProjectListController = SandboxedModule.require(MODULE_PATH, {
requires: {
@ -149,6 +154,7 @@ describe('ProjectListController', function () {
'../User/UserPrimaryEmailCheckHandler':
this.UserPrimaryEmailCheckHandler,
'../Notifications/NotificationsBuilder': this.NotificationBuilder,
'../Subscription/SubscriptionLocator': this.SubscriptionLocator,
},
})

View file

@ -47,6 +47,7 @@ describe('TeamInvitesHandler', function () {
promises: {
getUser: sinon.stub().resolves(),
getUserByAnyEmail: sinon.stub().resolves(),
getUserByMainEmail: sinon.stub().resolves(),
},
}
@ -91,10 +92,23 @@ describe('TeamInvitesHandler', function () {
this.UserGetter.promises.getUserByAnyEmail
.withArgs(this.manager.email)
.resolves(this.manager)
this.UserGetter.promises.getUserByMainEmail
.withArgs(this.manager.email)
.resolves(this.manager)
this.SubscriptionLocator.promises.getUsersSubscription.resolves(
this.subscription
)
this.NotificationsBuilder = {
promises: {
groupInvitation: sinon.stub().returns({
create: sinon.stub().resolves(),
read: sinon.stub().resolves(),
}),
},
}
this.Subscription.findOne.resolves(this.subscription)
this.TeamInvitesHandler = SandboxedModule.require(modulePath, {
@ -110,6 +124,7 @@ describe('TeamInvitesHandler', function () {
'./LimitationsManager': this.LimitationsManager,
'../Email/EmailHandler': this.EmailHandler,
'./ManagedUsersHandler': this.ManagedUsersHandler,
'../Notifications/NotificationsBuilder': this.NotificationsBuilder,
},
})
})
@ -242,6 +257,34 @@ describe('TeamInvitesHandler', function () {
}
)
})
it('sends a notification if inviting registered user', function (done) {
const id = new ObjectId('6a6b3a8014829a865bbf700d')
const managedUsersEnabled = false
this.UserGetter.promises.getUserByMainEmail
.withArgs('john.snow@example.com')
.resolves({
_id: id,
})
this.TeamInvitesHandler.createInvite(
this.manager._id,
this.subscription,
'John.Snow@example.com',
(err, invite) => {
this.NotificationsBuilder.promises
.groupInvitation(
id.toString(),
this.subscription._id,
managedUsersEnabled
)
.create.calledWith(invite)
.should.eq(true)
done(err)
}
)
})
})
describe('importInvite', function () {
@ -311,6 +354,21 @@ describe('TeamInvitesHandler', function () {
done()
})
})
it('removes dashboard notification after they accepted group invitation', function (done) {
const managedUsersEnabled = false
this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
sinon.assert.called(
this.NotificationsBuilder.promises.groupInvitation(
this.user.id,
this.subscription._id,
managedUsersEnabled
).read
)
done()
})
})
})
describe('revokeInvite', function () {
@ -337,6 +395,36 @@ describe('TeamInvitesHandler', function () {
}
)
})
it('removes dashboard notification for pending group invitation', function (done) {
const managedUsersEnabled = false
const pendingUser = {
id: '1a2b',
email: 'tyrion@example.com',
}
this.UserGetter.promises.getUserByAnyEmail
.withArgs(pendingUser.email)
.resolves(pendingUser)
this.TeamInvitesHandler.revokeInvite(
this.manager._id,
this.subscription,
pendingUser.email,
() => {
sinon.assert.called(
this.NotificationsBuilder.promises.groupInvitation(
pendingUser.id,
this.subscription._id,
managedUsersEnabled
).read
)
done()
}
)
})
})
describe('createTeamInvitesForLegacyInvitedEmail', function (done) {

View file

@ -1,19 +1,86 @@
export type Notification = {
type TemplateKey =
| 'notification_project_invite'
| 'wfh_2020_upgrade_offer'
| 'notification_ip_matched_affiliation'
| 'notification_tpds_file_limit'
| 'notification_dropbox_duplicate_project_names'
| 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation'
| 'notification_group_invitation'
type NotificationBase = {
_id?: number
templateKey: string
html?: string
templateKey: TemplateKey | string
}
export interface NotificationProjectInvite extends NotificationBase {
templateKey: Extract<TemplateKey, 'notification_project_invite'>
messageOpts: {
projectId: number | string
projectName: string
portalPath?: string
ssoEnabled: boolean
institutionId: string
userName: string
university_name: string
projectId: number | string
token: string
}
html?: string
}
interface NotificationWFH2020UpgradeOffer extends NotificationBase {
templateKey: Extract<TemplateKey, 'wfh_2020_upgrade_offer'>
}
export interface NotificationIPMatchedAffiliation extends NotificationBase {
templateKey: Extract<TemplateKey, 'notification_ip_matched_affiliation'>
messageOpts: {
university_name: string
ssoEnabled: boolean
portalPath?: string
institutionId: string
}
}
export interface NotificationTPDSFileLimit extends NotificationBase {
templateKey: Extract<TemplateKey, 'notification_tpds_file_limit'>
messageOpts: {
projectName: string
}
}
export interface NotificationDropboxDuplicateProjectNames
extends NotificationBase {
templateKey: Extract<
TemplateKey,
'notification_dropbox_duplicate_project_names'
>
messageOpts: {
projectName: string
}
}
interface NotificationDropboxUnlinkedDueToLapsedReconfirmation
extends NotificationBase {
templateKey: Extract<
TemplateKey,
'notification_dropbox_unlinked_due_to_lapsed_reconfirmation'
>
}
export interface NotificationGroupInvitation extends NotificationBase {
templateKey: Extract<TemplateKey, 'notification_group_invitation'>
messageOpts: {
token: string
inviterName: string
managedUsersEnabled: boolean
}
}
export type Notification =
| NotificationProjectInvite
| NotificationWFH2020UpgradeOffer
| NotificationIPMatchedAffiliation
| NotificationTPDSFileLimit
| NotificationDropboxDuplicateProjectNames
| NotificationDropboxUnlinkedDueToLapsedReconfirmation
| NotificationGroupInvitation
export type Institution = {
_id?: number
email: string