diff --git a/services/web/app/src/Features/Notifications/NotificationsBuilder.js b/services/web/app/src/Features/Notifications/NotificationsBuilder.js
index ea061ee65e..0887667501 100644
--- a/services/web/app/src/Features/Notifications/NotificationsBuilder.js
+++ b/services/web/app/src/Features/Notifications/NotificationsBuilder.js
@@ -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
diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js
index 287cb9d78d..a9cea74876 100644
--- a/services/web/app/src/Features/Project/ProjectListController.js
+++ b/services/web/app/src/Features/Project/ProjectListController.js
@@ -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,
})
}
diff --git a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js
index 301a2b0023..d5bb116bba 100644
--- a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js
+++ b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js
@@ -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(
{
diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug
index db50495447..ab5c905597 100644
--- a/services/web/app/views/project/list-react.pug
+++ b/services/web/app/views/project/list-react.pug
@@ -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
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 3de17ed8ef..34ac83fdcf 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -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": "",
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
index 2a63fe6dd9..5b1a4fcd61 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
@@ -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) {
}}
- values={{ projectName: messageOpts.projectName }}
+ values={{ projectName: notification.messageOpts.projectName }}
/>
) : (
}}
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')}
@@ -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={[]} // eslint-disable-line react/jsx-key
values={{
- institutionName: messageOpts.university_name,
+ institutionName: notification.messageOpts.university_name,
}}
/>
- {messageOpts.ssoEnabled ? (
+ {notification.messageOpts.ssoEnabled ? (
<>
@@ -155,7 +160,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
i18nKey="did_you_know_institution_providing_professional"
components={[]} // eslint-disable-line react/jsx-key
values={{
- institutionName: messageOpts.university_name,
+ institutionName: notification.messageOpts.university_name,
}}
/>
@@ -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')}
@@ -186,8 +191,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
onDismiss={() => id && handleDismiss(id)}
>
- Error: Your project {messageOpts.projectName} has gone over the 2000
- file limit using an integration (e.g. Dropbox or GitHub)
+ Error: Your project {notification.messageOpts.projectName} has gone
+ over the 2000 file limit using an integration (e.g. Dropbox or
+ GitHub)
Please decrease the size of your project to prevent further errors.
@@ -211,7 +217,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
]} // eslint-disable-line react/jsx-key
- values={{ projectName: messageOpts.projectName }}
+ values={{ projectName: notification.messageOpts.projectName }}
/>
@@ -248,6 +254,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
+ ) : templateKey === 'notification_group_invitation' ? (
+
) : (
id && handleDismiss(id)}>
{html}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx
new file mode 100644
index 0000000000..a73ef74b99
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx
@@ -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>
+ cancelPersonalSubscription: () => void
+ dismissGroupInviteNotification: () => void
+ notification: NotificationGroupInvitation
+}
+
+export default function GroupInvitationCancelIndividualSubscriptionNotification({
+ setGroupInvitationStatus,
+ cancelPersonalSubscription,
+ dismissGroupInviteNotification,
+ notification,
+}: GroupInvitationCancelIndividualSubscriptionNotificationProps) {
+ const { t } = useTranslation()
+ const {
+ messageOpts: { inviterName },
+ } = notification
+
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx
new file mode 100644
index 0000000000..ddca55838b
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-successful.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-successful.tsx
new file mode 100644
index 0000000000..b919fee9e5
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-successful.tsx
@@ -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 (
+
+
+
+ {t('congratulations_youve_successfully_join_group')}
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation.tsx
new file mode 100644
index 0000000000..001a4d92f7
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation.tsx
@@ -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 (
+
+ )
+ case GroupInvitationStatus.AskToJoin:
+ return (
+
+ )
+ case GroupInvitationStatus.SuccessfullyJoined:
+ return (
+
+ )
+ default:
+ return null
+ }
+}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx
new file mode 100644
index 0000000000..0d14c34f28
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx
@@ -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>
+ 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.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,
+ }
+}
diff --git a/services/web/frontend/stories/project-list/helpers/emails.ts b/services/web/frontend/stories/project-list/helpers/emails.ts
index ce0ab9abaa..264149a351 100644
--- a/services/web/frontend/stories/project-list/helpers/emails.ts
+++ b/services/web/frontend/stories/project-list/helpers/emails.ts
@@ -31,18 +31,6 @@ export const fakeReconfirmationUsersData = {
default: false,
} as DeepReadonly
-const fakeNotificationData = {
- messageOpts: {
- projectId: '123',
- projectName: 'Abc Project',
- ssoEnabled: false,
- institutionId: '456',
- userName: 'fakeUser',
- university_name: 'Abc University',
- token: 'abcdef',
- },
-} as DeepReadonly
-
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) {
setDefaultMeta()
- window.metaAttributesCache.set('ol-notifications', [
- merge(cloneDeep(fakeNotificationData), notificationData),
- ])
+ window.metaAttributesCache.set('ol-notifications', [notificationData])
}
export function commonSetupMocks(fetchMock: FetchMockStatic) {
diff --git a/services/web/frontend/stories/project-list/notifications.stories.tsx b/services/web/frontend/stories/project-list/notifications.stories.tsx
index 208142ddb7..c165f32428 100644
--- a/services/web/frontend/stories/project-list/notifications.stories.tsx
+++ b/services/web/frontend/stories/project-list/notifications.stories.tsx
@@ -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 (
+
+
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
+
export const NonSpecificMessage = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({ _id: 1, html: 'Non specific message' })
diff --git a/services/web/frontend/stylesheets/components/notifications.less b/services/web/frontend/stylesheets/components/notifications.less
index 18ac669655..1f189c9a2b 100644
--- a/services/web/frontend/stylesheets/components/notifications.less
+++ b/services/web/frontend/stylesheets/components/notifications.less
@@ -194,6 +194,11 @@
}
}
+.group-invitation-cancel-subscription-notification-buttons {
+ display: flex;
+ align-items: center;
+}
+
// Settings page
.affiliations-table {
.reconfirm-notification {
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 5829c1348b..927ce0de0d 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -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",
diff --git a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx
new file mode 100644
index 0000000000..4c09083177
--- /dev/null
+++ b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx
@@ -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 (
+
+ )
+}
+
+describe('', 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()
+
+ 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()
+
+ 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()
+
+ 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')
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx
index e2f0d87170..c06dbc826a 100644
--- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx
@@ -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('', 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('', 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('', 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('', 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('', function () {
templateKey: 'wfh_2020_upgrade_offer',
}
window.metaAttributesCache.set('ol-notifications', [
- merge(cloneDeep(notification), reconfiguredNotification),
+ merge(reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
@@ -202,7 +206,10 @@ describe('', function () {
messageOpts: { ssoEnabled: true },
}
window.metaAttributesCache.set('ol-notifications', [
- merge(cloneDeep(notification), reconfiguredNotification),
+ merge(
+ cloneDeep(notificationIPMatchedAffiliation),
+ reconfiguredNotification
+ ),
])
renderWithinProjectListProvider(Common)
@@ -222,7 +229,7 @@ describe('', 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('', function () {
messageOpts: { ssoEnabled: false },
}
window.metaAttributesCache.set('ol-notifications', [
- merge(cloneDeep(notification), reconfiguredNotification),
+ merge(
+ cloneDeep(notificationIPMatchedAffiliation),
+ reconfiguredNotification
+ ),
])
renderWithinProjectListProvider(Common)
@@ -268,7 +278,7 @@ describe('', 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('', 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('', 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('', function () {
html: 'unspecific message',
}
window.metaAttributesCache.set('ol-notifications', [
- merge(cloneDeep(notification), reconfiguredNotification),
+ reconfiguredNotification,
])
renderWithinProjectListProvider(Common)
@@ -371,6 +387,68 @@ describe('', function () {
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
+
+ describe('', function () {
+ describe('without existing personal subscription', function () {
+ it('shows group invitation notification for user without personal subscription', async function () {
+ const notificationGroupInvite: DeepPartial = {
+ _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 = {
+ _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('', function () {
diff --git a/services/web/test/frontend/features/project-list/fixtures/notifications-data.ts b/services/web/test/frontend/features/project-list/fixtures/notifications-data.ts
index 06f3c38943..56bcc9e8e9 100644
--- a/services/web/test/frontend/features/project-list/fixtures/notifications-data.ts
+++ b/services/web/test/frontend/features/project-list/fixtures/notifications-data.ts
@@ -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
-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
+} as DeepReadonly
+
+export const notificationIPMatchedAffiliation = {
+ messageOpts: {
+ university_name: 'Abc University',
+ ssoEnabled: false,
+ institutionId: '456',
+ },
+} as DeepReadonly
+
+export const notificationTPDSFileLimit = {
+ messageOpts: {
+ projectName: 'Abc Project',
+ },
+} as DeepReadonly
+
+export const notificationDropboxDuplicateProjectNames = {
+ messageOpts: {
+ projectName: 'Abc Project',
+ },
+} as DeepReadonly
+
+export const notificationGroupInviteDefault = {
+ messageOpts: {
+ token: '123abc',
+ inviterName: 'inviter@overleaf.com',
+ managedUsersEnabled: false,
+ },
+} as DeepReadonly
+
+export const notificationGroupInviteManagedUsers = {
+ messageOpts: {
+ token: '123abc',
+ inviterName: 'inviter@overleaf.com',
+ managedUsersEnabled: true,
+ },
+} as DeepReadonly
diff --git a/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js b/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js
index 88ca7a125b..234cf7547e 100644
--- a/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js
+++ b/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js
@@ -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 () {
diff --git a/services/web/test/unit/src/Project/ProjectListControllerTests.js b/services/web/test/unit/src/Project/ProjectListControllerTests.js
index e4eb3522f3..e5dcf0905e 100644
--- a/services/web/test/unit/src/Project/ProjectListControllerTests.js
+++ b/services/web/test/unit/src/Project/ProjectListControllerTests.js
@@ -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,
},
})
diff --git a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js
index 59eea28c2d..7d1d42dd08 100644
--- a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js
+++ b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js
@@ -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) {
diff --git a/services/web/types/project/dashboard/notification.ts b/services/web/types/project/dashboard/notification.ts
index 52cd9107ba..22d616845b 100644
--- a/services/web/types/project/dashboard/notification.ts
+++ b/services/web/types/project/dashboard/notification.ts
@@ -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
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
+}
+
+export interface NotificationIPMatchedAffiliation extends NotificationBase {
+ templateKey: Extract
+ messageOpts: {
+ university_name: string
+ ssoEnabled: boolean
+ portalPath?: string
+ institutionId: string
+ }
+}
+
+export interface NotificationTPDSFileLimit extends NotificationBase {
+ templateKey: Extract
+ 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
+ messageOpts: {
+ token: string
+ inviterName: string
+ managedUsersEnabled: boolean
+ }
+}
+
+export type Notification =
+ | NotificationProjectInvite
+ | NotificationWFH2020UpgradeOffer
+ | NotificationIPMatchedAffiliation
+ | NotificationTPDSFileLimit
+ | NotificationDropboxDuplicateProjectNames
+ | NotificationDropboxUnlinkedDueToLapsedReconfirmation
+ | NotificationGroupInvitation
+
export type Institution = {
_id?: number
email: string