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