Procházet zdrojové kódy

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

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

GitOrigin-RevId: ad03dfea95f0d5d1a38780adc3e9d618eae0a48d
M Fahru před 2 roky
rodič
revize
8111ff2865
22 změnil soubory, kde provedl 985 přidání a 61 odebrání
  1. 35 0
      services/web/app/src/Features/Notifications/NotificationsBuilder.js
  2. 16 0
      services/web/app/src/Features/Project/ProjectListController.js
  3. 52 1
      services/web/app/src/Features/Subscription/TeamInvitesHandler.js
  4. 1 0
      services/web/app/views/project/list-react.pug
  5. 6 0
      services/web/frontend/extracted-translations.json
  6. 26 18
      services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
  7. 53 0
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx
  8. 47 0
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx
  9. 22 0
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-successful.tsx
  10. 55 0
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation.tsx
  11. 132 0
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx
  12. 1 15
      services/web/frontend/stories/project-list/helpers/emails.ts
  13. 58 0
      services/web/frontend/stories/project-list/notifications.stories.tsx
  14. 5 0
      services/web/frontend/stylesheets/components/notifications.less
  15. 4 0
      services/web/locales/en.json
  16. 133 0
      services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx
  17. 92 14
      services/web/test/frontend/features/project-list/components/notifications.test.tsx
  18. 43 6
      services/web/test/frontend/features/project-list/fixtures/notifications-data.ts
  19. 36 0
      services/web/test/unit/src/Notifications/NotificationsBuilderTests.js
  20. 6 0
      services/web/test/unit/src/Project/ProjectListControllerTests.js
  21. 88 0
      services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js
  22. 74 7
      services/web/types/project/dashboard/notification.ts

+ 35 - 0
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 = {
 const NotificationsBuilder = {
   // Note: notification keys should be url-safe
   // Note: notification keys should be url-safe
   dropboxUnlinkedDueToLapsedReconfirmation,
   dropboxUnlinkedDueToLapsedReconfirmation,
@@ -233,6 +264,7 @@ const NotificationsBuilder = {
   projectInvite,
   projectInvite,
   ipMatcherAffiliation,
   ipMatcherAffiliation,
   tpdsFileLimit,
   tpdsFileLimit,
+  groupInvitation,
 }
 }
 
 
 NotificationsBuilder.promises = {
 NotificationsBuilder.promises = {
@@ -248,6 +280,9 @@ NotificationsBuilder.promises = {
   ipMatcherAffiliation: function (userId) {
   ipMatcherAffiliation: function (userId) {
     return promisifyAll(ipMatcherAffiliation(userId))
     return promisifyAll(ipMatcherAffiliation(userId))
   },
   },
+  groupInvitation: function (userId, groupId, managedUsersEnabled) {
+    return promisifyAll(groupInvitation(userId, groupId, managedUsersEnabled))
+  },
 }
 }
 
 
 module.exports = NotificationsBuilder
 module.exports = NotificationsBuilder

+ 16 - 0
services/web/app/src/Features/Project/ProjectListController.js

@@ -23,6 +23,7 @@ const LimitationsManager = require('../Subscription/LimitationsManager')
 const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
 const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
 const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
 const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
 const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 const SplitTestHandler = require('../SplitTests/SplitTestHandler')
+const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
 
 
 /** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */
 /** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */
 /** @typedef {import("./types").GetProjectsResponse} GetProjectsResponse */
 /** @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', {
   res.render('project/list-react', {
     title: 'your_projects',
     title: 'your_projects',
     usersBestSubscription,
     usersBestSubscription,
@@ -462,6 +477,7 @@ async function projectListPage(req, res, next) {
         groupId: subscription._id,
         groupId: subscription._id,
         groupName: subscription.teamName,
         groupName: subscription.teamName,
       })),
       })),
+    hasIndividualRecurlySubscription,
   })
   })
 }
 }
 
 

+ 52 - 1
services/web/app/src/Features/Subscription/TeamInvitesHandler.js

@@ -17,6 +17,7 @@ const EmailHelper = require('../Helpers/EmailHelper')
 
 
 const Errors = require('../Errors/Errors')
 const Errors = require('../Errors/Errors')
 const { callbackify, callbackifyMultiResult } = require('../../util/promises')
 const { callbackify, callbackifyMultiResult } = require('../../util/promises')
+const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
 
 
 async function getInvite(token) {
 async function getInvite(token) {
   const subscription = await Subscription.findOne({
   const subscription = await Subscription.findOne({
@@ -75,14 +76,29 @@ async function acceptInvite(token, userId) {
   }
   }
 
 
   await _removeInviteFromTeam(subscription.id, invite.email)
   await _removeInviteFromTeam(subscription.id, invite.email)
+
+  await NotificationsBuilder.promises
+    .groupInvitation(userId, subscription._id, false)
+    .read()
 }
 }
 
 
 async function revokeInvite(teamManagerId, subscription, email) {
 async function revokeInvite(teamManagerId, subscription, email) {
   email = EmailHelper.parseEmail(email)
   email = EmailHelper.parseEmail(email)
+
   if (!email) {
   if (!email) {
     throw new Error('invalid email')
     throw new Error('invalid email')
   }
   }
+
   await _removeInviteFromTeam(subscription.id, 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
 // 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(
   const teams = await SubscriptionLocator.promises.getGroupsWithEmailInvite(
     email
     email
   )
   )
-
   return Promise.all(
   return Promise.all(
     teams.map(team => createInvite(team.admin_id, team, email))
     teams.map(team => createInvite(team.admin_id, team, email))
   )
   )
@@ -145,6 +160,21 @@ async function _createInvite(subscription, email, inviter) {
     subscription.teamInvites.push(invite)
     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()
   await subscription.save()
 
 
   if (subscription.groupPolicy) {
   if (subscription.groupPolicy) {
@@ -201,6 +231,27 @@ async function _removeInviteFromTeam(subscriptionId, email, callback) {
   await _removeLegacyInvite(subscriptionId, email)
   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) {
 async function _removeLegacyInvite(subscriptionId, email) {
   await Subscription.updateOne(
   await Subscription.updateOne(
     {
     {

+ 1 - 0
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-showBackToSchoolModal" data-type="boolean" content=showBackToSchoolModal)
 	meta(name="ol-welcomePageRedesignVariant" data-type="string" content=welcomePageRedesignVariant)
 	meta(name="ol-welcomePageRedesignVariant" data-type="string" content=welcomePageRedesignVariant)
 	meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment)
 	meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment)
+	meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription)
 
 
 block content
 block content
 	main.content.content-alt.project-list-react#project-list-root
 	main.content.content-alt.project-list-react#project-list-root

+ 6 - 0
services/web/frontend/extracted-translations.json

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

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

+ 53 - 0
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<SetStateAction<GroupInvitationStatus>>
+  cancelPersonalSubscription: () => void
+  dismissGroupInviteNotification: () => void
+  notification: NotificationGroupInvitation
+}
+
+export default function GroupInvitationCancelIndividualSubscriptionNotification({
+  setGroupInvitationStatus,
+  cancelPersonalSubscription,
+  dismissGroupInviteNotification,
+  notification,
+}: GroupInvitationCancelIndividualSubscriptionNotificationProps) {
+  const { t } = useTranslation()
+  const {
+    messageOpts: { inviterName },
+  } = notification
+
+  return (
+    <Notification bsStyle="info" onDismiss={dismissGroupInviteNotification}>
+      <Notification.Body>
+        <Trans
+          i18nKey="invited_to_group_have_individual_subcription"
+          values={{
+            inviterName,
+          }}
+        />
+      </Notification.Body>
+      <Notification.Action className="group-invitation-cancel-subscription-notification-buttons">
+        <Button
+          bsStyle="info"
+          bsSize="sm"
+          className="me-1"
+          onClick={() =>
+            setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
+          }
+        >
+          {t('not_now')}
+        </Button>
+        <Button bsStyle="info" bsSize="sm" onClick={cancelPersonalSubscription}>
+          {t('cancel_my_subscription')}
+        </Button>
+      </Notification.Action>
+    </Notification>
+  )
+}

+ 47 - 0
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 (
+    <Notification bsStyle="info" onDismiss={dismissGroupInviteNotification}>
+      <Notification.Body>
+        <Trans
+          i18nKey="invited_to_group"
+          values={{
+            inviterName,
+          }}
+        />
+      </Notification.Body>
+      <Notification.Action>
+        <Button
+          bsStyle="info"
+          bsSize="sm"
+          className="pull-right"
+          onClick={acceptGroupInvite}
+          disabled={isAcceptingInvitation}
+        >
+          {t('join_now')}
+        </Button>
+      </Notification.Action>
+    </Notification>
+  )
+}

+ 22 - 0
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 (
+    <Notification bsStyle="success" onDismiss={hideNotification}>
+      <Notification.Body>
+        <Icon type="check-circle" fw aria-hidden="true" className="me-1" />
+        {t('congratulations_youve_successfully_join_group')}
+      </Notification.Body>
+    </Notification>
+  )
+}

+ 55 - 0
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 (
+        <GroupInvitationCancelIndividualSubscriptionNotification
+          setGroupInvitationStatus={setGroupInvitationStatus}
+          cancelPersonalSubscription={cancelPersonalSubscription}
+          dismissGroupInviteNotification={dismissGroupInviteNotification}
+          notification={notification}
+        />
+      )
+    case GroupInvitationStatus.AskToJoin:
+      return (
+        <GroupInvitationNotificationJoin
+          isAcceptingInvitation={isAcceptingInvitation}
+          notification={notification}
+          acceptGroupInvite={acceptGroupInvite}
+          dismissGroupInviteNotification={dismissGroupInviteNotification}
+        />
+      )
+    case GroupInvitationStatus.SuccessfullyJoined:
+      return (
+        <GroupInvitationSuccessfulNotification
+          hideNotification={hideNotification}
+        />
+      )
+    default:
+      return null
+  }
+}

+ 132 - 0
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<SetStateAction<GroupInvitationStatus>>
+  acceptGroupInvite: () => void
+  cancelPersonalSubscription: () => void
+  dismissGroupInviteNotification: () => void
+  hideNotification: () => void
+}
+
+export function useGroupInvitationNotification(
+  notification: NotificationGroupInvitation
+): UseGroupInvitationNotificationReturnType {
+  const {
+    _id: notificationId,
+    messageOpts: { token, managedUsersEnabled },
+  } = notification
+
+  const [groupInvitationStatus, setGroupInvitationStatus] =
+    useState<GroupInvitationStatus>(GroupInvitationStatus.Idle)
+  const { runAsync, isLoading: isAcceptingInvitation } = useAsync<
+    never,
+    FetchError
+  >()
+  const location = useLocation()
+  const { handleDismiss } = useAsyncDismiss()
+
+  const hasIndividualRecurlySubscription = getMeta(
+    'ol-hasIndividualRecurlySubscription'
+  ) as boolean | undefined
+
+  useEffect(() => {
+    if (hasIndividualRecurlySubscription) {
+      setGroupInvitationStatus(
+        GroupInvitationStatus.CancelIndividualSubscription
+      )
+    } else {
+      setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
+    }
+  }, [hasIndividualRecurlySubscription])
+
+  const acceptGroupInvite = useCallback(() => {
+    if (managedUsersEnabled) {
+      location.assign(`/subscription/invites/${token}/`)
+    } else {
+      runAsync(
+        putJSON(`/subscription/invites/${token}/`, {
+          body: {
+            _csrf: getMeta('ol-csrfToken'),
+          },
+        })
+      )
+        .then(() => {
+          setGroupInvitationStatus(GroupInvitationStatus.SuccessfullyJoined)
+        })
+        .catch(err => {
+          setGroupInvitationStatus(GroupInvitationStatus.Error)
+
+          console.error(err)
+        })
+        .finally(() => {
+          // remove notification automatically in the browser
+          window.setTimeout(() => {
+            setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden)
+          }, SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN)
+        })
+    }
+  }, [runAsync, token, location, managedUsersEnabled])
+
+  const cancelPersonalSubscription = useCallback(() => {
+    setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
+
+    runAsync(
+      postJSON('/user/subscription/cancel', {
+        body: {
+          _csrf: getMeta('ol-csrfToken'),
+        },
+      })
+    ).catch(console.error)
+  }, [runAsync])
+
+  const dismissGroupInviteNotification = useCallback(() => {
+    if (notificationId) {
+      handleDismiss(notificationId)
+    }
+  }, [handleDismiss, notificationId])
+
+  const hideNotification = useCallback(() => {
+    setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden)
+  }, [])
+
+  return {
+    isAcceptingInvitation,
+    groupInvitationStatus,
+    setGroupInvitationStatus,
+    acceptGroupInvite,
+    cancelPersonalSubscription,
+    dismissGroupInviteNotification,
+    hideNotification,
+  }
+}

+ 1 - 15
services/web/frontend/stories/project-list/helpers/emails.ts

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

+ 58 - 0
services/web/frontend/stories/project-list/notifications.stories.tsx

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

+ 5 - 0
services/web/frontend/stylesheets/components/notifications.less

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

+ 4 - 0
services/web/locales/en.json

@@ -199,6 +199,7 @@
   "cancel": "Cancel",
   "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_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_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_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",
   "cancel_your_subscription": "Cancel Your Subscription",
   "cannot_invite_non_user": "Can’t send invite. Recipient must already have an __appName__ account",
   "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.",
   "confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
   "confirming": "Confirming",
   "confirming": "Confirming",
   "conflicting_paths_found": "Conflicting Paths Found",
   "conflicting_paths_found": "Conflicting Paths Found",
+  "congratulations_youve_successfully_join_group": "Congratulations! You‘ve successfully joined the group subscription.",
   "connected_users": "Connected Users",
   "connected_users": "Connected Users",
   "connecting": "Connecting",
   "connecting": "Connecting",
   "contact": "Contact",
   "contact": "Contact",
@@ -863,6 +865,7 @@
   "invite_not_valid": "This is not a valid project invite",
   "invite_not_valid": "This is not a valid project invite",
   "invite_not_valid_description": "The invite may have expired. Please contact the project owner",
   "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": "__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": "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_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.",
   "invited_to_group_register": "To accept __inviterName__’s invitation you’ll need to create an account.",
@@ -876,6 +879,7 @@
   "ja": "Japanese",
   "ja": "Japanese",
   "january": "January",
   "january": "January",
   "join_beta_program": "Join beta program",
   "join_beta_program": "Join beta program",
+  "join_now": "Join now",
   "join_project": "Join Project",
   "join_project": "Join Project",
   "join_sl_to_view_project": "Join __appName__ to view this 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",
   "join_team_explanation": "Please click the button below to join the group subscription and enjoy the benefits of an upgraded __appName__ account",

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

+ 92 - 14
services/web/test/frontend/features/project-list/components/notifications.test.tsx

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

+ 43 - 6
services/web/test/frontend/features/project-list/fixtures/notifications-data.ts

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

+ 36 - 0
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('ipMatcherAffiliation', function () {
     describe('with portal and with SSO', function () {
     describe('with portal and with SSO', function () {
       beforeEach(function () {
       beforeEach(function () {

+ 6 - 0
services/web/test/unit/src/Project/ProjectListControllerTests.js

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

+ 88 - 0
services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js

@@ -47,6 +47,7 @@ describe('TeamInvitesHandler', function () {
       promises: {
       promises: {
         getUser: sinon.stub().resolves(),
         getUser: sinon.stub().resolves(),
         getUserByAnyEmail: sinon.stub().resolves(),
         getUserByAnyEmail: sinon.stub().resolves(),
+        getUserByMainEmail: sinon.stub().resolves(),
       },
       },
     }
     }
 
 
@@ -91,10 +92,23 @@ describe('TeamInvitesHandler', function () {
     this.UserGetter.promises.getUserByAnyEmail
     this.UserGetter.promises.getUserByAnyEmail
       .withArgs(this.manager.email)
       .withArgs(this.manager.email)
       .resolves(this.manager)
       .resolves(this.manager)
+    this.UserGetter.promises.getUserByMainEmail
+      .withArgs(this.manager.email)
+      .resolves(this.manager)
 
 
     this.SubscriptionLocator.promises.getUsersSubscription.resolves(
     this.SubscriptionLocator.promises.getUsersSubscription.resolves(
       this.subscription
       this.subscription
     )
     )
+
+    this.NotificationsBuilder = {
+      promises: {
+        groupInvitation: sinon.stub().returns({
+          create: sinon.stub().resolves(),
+          read: sinon.stub().resolves(),
+        }),
+      },
+    }
+
     this.Subscription.findOne.resolves(this.subscription)
     this.Subscription.findOne.resolves(this.subscription)
 
 
     this.TeamInvitesHandler = SandboxedModule.require(modulePath, {
     this.TeamInvitesHandler = SandboxedModule.require(modulePath, {
@@ -110,6 +124,7 @@ describe('TeamInvitesHandler', function () {
         './LimitationsManager': this.LimitationsManager,
         './LimitationsManager': this.LimitationsManager,
         '../Email/EmailHandler': this.EmailHandler,
         '../Email/EmailHandler': this.EmailHandler,
         './ManagedUsersHandler': this.ManagedUsersHandler,
         './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 () {
   describe('importInvite', function () {
@@ -311,6 +354,21 @@ describe('TeamInvitesHandler', function () {
         done()
         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 () {
   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) {
   describe('createTeamInvitesForLegacyInvitedEmail', function (done) {

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