mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-11 17:35:33 +00:00
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:
parent
27bdf9fb0b
commit
8111ff2865
22 changed files with 986 additions and 62 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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' })
|
||||
|
|
|
@ -194,6 +194,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.group-invitation-cancel-subscription-notification-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Settings page
|
||||
.affiliations-table {
|
||||
.reconfirm-notification {
|
||||
|
|
|
@ -199,6 +199,7 @@
|
|||
"cancel": "Cancel",
|
||||
"cancel_anytime": "We’re confident that you’ll love __appName__, but if not you can cancel anytime. We’ll 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": "Can’t 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! You‘ve 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, you’ll 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 you’ll 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",
|
||||
|
|
|
@ -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! You‘ve 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! You‘ve 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! You‘ve successfully joined the group subscription.'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: /close/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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 () {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue