Просмотр исходного кода

Merge pull request #14442 from overleaf/mf-resend-group-invite

[web] Add an option to resend group invite in managed users setting

GitOrigin-RevId: 75625c5a50dfc74b48b3a465c9f713e2d6179db8
M Fahru 3 лет назад
Родитель
Сommit
a834e02cd5

+ 12 - 4
services/web/app/src/Features/Email/EmailBuilder.js

@@ -362,12 +362,16 @@ templates.verifyEmailToJoinTeam = ctaTemplate({
 
 
 templates.verifyEmailToJoinManagedUsers = ctaTemplate({
 templates.verifyEmailToJoinManagedUsers = ctaTemplate({
   subject(opts) {
   subject(opts) {
-    return `You’ve been invited by ${_.escape(
+    return `${
+      opts.reminder ? 'Reminder: ' : ''
+    }You’ve been invited by ${_.escape(
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
     )} to join an ${settings.appName} group subscription.`
     )} to join an ${settings.appName} group subscription.`
   },
   },
   title(opts) {
   title(opts) {
-    return `You’ve been invited by ${_.escape(
+    return `${
+      opts.reminder ? 'Reminder: ' : ''
+    }You’ve been invited by ${_.escape(
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
     )} to join an ${settings.appName} group subscription.`
     )} to join an ${settings.appName} group subscription.`
   },
   },
@@ -404,12 +408,16 @@ templates.verifyEmailToJoinManagedUsers = ctaTemplate({
 
 
 templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
 templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
   subject(opts) {
   subject(opts) {
-    return `You’ve been invited by ${_.escape(
+    return `${
+      opts.reminder ? 'Reminder: ' : ''
+    }You’ve been invited by ${_.escape(
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
     )} to join an ${settings.appName} group subscription.`
     )} to join an ${settings.appName} group subscription.`
   },
   },
   title(opts) {
   title(opts) {
-    return `You’ve been invited by ${_.escape(
+    return `${
+      opts.reminder ? 'Reminder: ' : ''
+    }You’ve been invited by ${_.escape(
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
       _formatUserNameAndEmail(opts.inviter, 'a collaborator')
     )} to join an ${settings.appName} group subscription.`
     )} to join an ${settings.appName} group subscription.`
   },
   },

+ 82 - 28
services/web/app/src/Features/Subscription/TeamInvitesController.js

@@ -1,5 +1,6 @@
 const settings = require('@overleaf/settings')
 const settings = require('@overleaf/settings')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
+const OError = require('@overleaf/o-error')
 const TeamInvitesHandler = require('./TeamInvitesHandler')
 const TeamInvitesHandler = require('./TeamInvitesHandler')
 const SessionManager = require('../Authentication/SessionManager')
 const SessionManager = require('../Authentication/SessionManager')
 const SubscriptionLocator = require('./SubscriptionLocator')
 const SubscriptionLocator = require('./SubscriptionLocator')
@@ -9,8 +10,17 @@ const UserGetter = require('../User/UserGetter')
 const { expressify } = require('../../util/promises')
 const { expressify } = require('../../util/promises')
 const HttpErrorHandler = require('../Errors/HttpErrorHandler')
 const HttpErrorHandler = require('../Errors/HttpErrorHandler')
 const PermissionsManager = require('../Authorization/PermissionsManager')
 const PermissionsManager = require('../Authorization/PermissionsManager')
+const EmailHandler = require('../Email/EmailHandler')
+const { RateLimiter } = require('../../infrastructure/RateLimiter')
+
+const rateLimiters = {
+  resendGroupInvite: new RateLimiter('resend-group-invite', {
+    points: 1,
+    duration: 60 * 60,
+  }),
+}
 
 
-function createInvite(req, res, next) {
+async function createInvite(req, res, next) {
   const teamManagerId = SessionManager.getLoggedInUserId(req.session)
   const teamManagerId = SessionManager.getLoggedInUserId(req.session)
   const subscription = req.entity
   const subscription = req.entity
   const email = EmailHelper.parseEmail(req.body.email)
   const email = EmailHelper.parseEmail(req.body.email)
@@ -23,33 +33,31 @@ function createInvite(req, res, next) {
     })
     })
   }
   }
 
 
-  TeamInvitesHandler.createInvite(
-    teamManagerId,
-    subscription,
-    email,
-    function (err, inviteUserData) {
-      if (err) {
-        if (err.alreadyInTeam) {
-          return res.status(400).json({
-            error: {
-              code: 'user_already_added',
-              message: req.i18n.translate('user_already_added'),
-            },
-          })
-        }
-        if (err.limitReached) {
-          return res.status(400).json({
-            error: {
-              code: 'group_full',
-              message: req.i18n.translate('group_full'),
-            },
-          })
-        }
-        return next(err)
-      }
-      res.json({ user: inviteUserData })
+  try {
+    const invitedUserData = await TeamInvitesHandler.promises.createInvite(
+      teamManagerId,
+      subscription,
+      email
+    )
+    return res.json({ user: invitedUserData })
+  } catch (err) {
+    if (err.alreadyInTeam) {
+      return res.status(400).json({
+        error: {
+          code: 'user_already_added',
+          message: req.i18n.translate('user_already_added'),
+        },
+      })
     }
     }
-  )
+    if (err.limitReached) {
+      return res.status(400).json({
+        error: {
+          code: 'group_full',
+          message: req.i18n.translate('group_full'),
+        },
+      })
+    }
+  }
 }
 }
 
 
 async function viewInvite(req, res, next) {
 async function viewInvite(req, res, next) {
@@ -178,9 +186,55 @@ function revokeInvite(req, res, next) {
   )
   )
 }
 }
 
 
+async function resendInvite(req, res, next) {
+  const { entity: subscription } = req
+  const userEmail = EmailHelper.parseEmail(req.body.email)
+  await subscription.populate('admin_id', ['email', 'first_name', 'last_name'])
+
+  if (!userEmail) {
+    throw new Error('invalid email')
+  }
+
+  const currentInvite = subscription.teamInvites.find(
+    invite => invite?.email === userEmail
+  )
+
+  if (!currentInvite) {
+    return await createInvite(req, res)
+  }
+
+  const opts = {
+    to: userEmail,
+    admin: subscription.admin_id,
+    inviter: currentInvite.inviterName,
+    acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${currentInvite.token}/`,
+    reminder: true,
+  }
+
+  try {
+    await rateLimiters.resendGroupInvite.consume(userEmail)
+
+    const existingUser = await UserGetter.promises.getUserByAnyEmail(userEmail)
+    const emailTemplate = existingUser
+      ? 'verifyEmailToJoinManagedUsers'
+      : 'inviteNewUserToJoinManagedUsers'
+
+    EmailHandler.sendDeferredEmail(emailTemplate, opts)
+  } catch (err) {
+    if (err?.remainingPoints === 0) {
+      return res.sendStatus(429)
+    } else {
+      throw OError.tag(err, 'Failed to resend group invite email')
+    }
+  }
+
+  return res.status(200).json({ success: true })
+}
+
 module.exports = {
 module.exports = {
-  createInvite,
+  createInvite: expressify(createInvite),
   viewInvite: expressify(viewInvite),
   viewInvite: expressify(viewInvite),
   acceptInvite: expressify(acceptInvite),
   acceptInvite: expressify(acceptInvite),
   revokeInvite,
   revokeInvite,
+  resendInvite: expressify(resendInvite),
 }
 }

+ 6 - 0
services/web/app/src/Features/UserMembership/UserMembershipRouter.js

@@ -30,6 +30,12 @@ module.exports = {
       RateLimiterMiddleware.rateLimit(rateLimiters.createTeamInvite),
       RateLimiterMiddleware.rateLimit(rateLimiters.createTeamInvite),
       TeamInvitesController.createInvite
       TeamInvitesController.createInvite
     )
     )
+    webRouter.post(
+      '/manage/groups/:id/resendInvite',
+      UserMembershipMiddleware.requireGroupManagementAccess,
+      RateLimiterMiddleware.rateLimit(rateLimiters.createTeamInvite),
+      TeamInvitesController.resendInvite
+    )
     webRouter.delete(
     webRouter.delete(
       '/manage/groups/:id/user/:user_id',
       '/manage/groups/:id/user/:user_id',
       UserMembershipMiddleware.requireGroupManagementAccess,
       UserMembershipMiddleware.requireGroupManagementAccess,

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

@@ -310,6 +310,7 @@
   "expires": "",
   "expires": "",
   "export_csv": "",
   "export_csv": "",
   "export_project_to_github": "",
   "export_project_to_github": "",
+  "failed_to_send_group_invite_to_email": "",
   "failed_to_send_managed_user_invite_to_email": "",
   "failed_to_send_managed_user_invite_to_email": "",
   "fast": "",
   "fast": "",
   "faster_compiles_feedback_question": "",
   "faster_compiles_feedback_question": "",
@@ -404,6 +405,7 @@
   "go_to_pdf_location_in_code": "",
   "go_to_pdf_location_in_code": "",
   "go_to_settings": "",
   "go_to_settings": "",
   "group_admin": "",
   "group_admin": "",
+  "group_invite_has_been_sent_to_email": "",
   "group_managed_by_group_administrator": "",
   "group_managed_by_group_administrator": "",
   "group_plan_tooltip": "",
   "group_plan_tooltip": "",
   "group_plan_with_name_tooltip": "",
   "group_plan_with_name_tooltip": "",
@@ -850,6 +852,7 @@
   "resend": "",
   "resend": "",
   "resend_confirmation_email": "",
   "resend_confirmation_email": "",
   "resend_email": "",
   "resend_email": "",
+  "resend_group_invite": "",
   "resend_managed_user_invite": "",
   "resend_managed_user_invite": "",
   "resending_confirmation_email": "",
   "resending_confirmation_email": "",
   "resolve": "",
   "resolve": "",

+ 57 - 3
services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx

@@ -17,7 +17,7 @@ import Icon from '../../../../shared/components/icon'
 import { ManagedUserAlert } from '../../utils/types'
 import { ManagedUserAlert } from '../../utils/types'
 import { useGroupMembersContext } from '../../context/group-members-context'
 import { useGroupMembersContext } from '../../context/group-members-context'
 
 
-type ResendManagedUserInviteResponse = {
+type resendInviteResponse = {
   success: boolean
   success: boolean
 }
 }
 
 
@@ -40,11 +40,18 @@ export default function ManagedUserDropdownButton({
   const {
   const {
     runAsync: runResendManagedUserInviteAsync,
     runAsync: runResendManagedUserInviteAsync,
     isLoading: isResendingManagedUserInvite,
     isLoading: isResendingManagedUserInvite,
-  } = useAsync<ResendManagedUserInviteResponse>()
+  } = useAsync<resendInviteResponse>()
+
+  const {
+    runAsync: runResendGroupInviteAsync,
+    isLoading: isResendingGroupInvite,
+  } = useAsync<resendInviteResponse>()
 
 
   const userNotManaged =
   const userNotManaged =
     !user.isEntityAdmin && !user.invite && !user.enrollment?.managedBy
     !user.isEntityAdmin && !user.invite && !user.enrollment?.managedBy
 
 
+  const userPending = user.invite
+
   const handleResendManagedUserInvite = useCallback(
   const handleResendManagedUserInvite = useCallback(
     async user => {
     async user => {
       try {
       try {
@@ -64,7 +71,7 @@ export default function ManagedUserDropdownButton({
       } catch (err) {
       } catch (err) {
         if ((err as FetchError)?.response?.status === 429) {
         if ((err as FetchError)?.response?.status === 429) {
           setManagedUserAlert({
           setManagedUserAlert({
-            variant: 'resendManagedUserInviteTooManyRequests',
+            variant: 'resendInviteTooManyRequests',
             email: user.email,
             email: user.email,
           })
           })
         } else {
         } else {
@@ -80,10 +87,49 @@ export default function ManagedUserDropdownButton({
     [setManagedUserAlert, groupId, runResendManagedUserInviteAsync]
     [setManagedUserAlert, groupId, runResendManagedUserInviteAsync]
   )
   )
 
 
+  const handleResendGroupInvite = useCallback(
+    async user => {
+      try {
+        await runResendGroupInviteAsync(
+          postJSON(`/manage/groups/${groupId}/resendInvite/`, {
+            body: {
+              email: user.email,
+            },
+          })
+        )
+
+        setManagedUserAlert({
+          variant: 'resendGroupInviteSuccess',
+          email: user.email,
+        })
+        setIsOpened(false)
+      } catch (err) {
+        if ((err as FetchError)?.response?.status === 429) {
+          setManagedUserAlert({
+            variant: 'resendInviteTooManyRequests',
+            email: user.email,
+          })
+        } else {
+          setManagedUserAlert({
+            variant: 'resendGroupInviteFailed',
+            email: user.email,
+          })
+        }
+
+        setIsOpened(false)
+      }
+    },
+    [setManagedUserAlert, groupId, runResendGroupInviteAsync]
+  )
+
   const onResendManagedUserInviteClick = () => {
   const onResendManagedUserInviteClick = () => {
     handleResendManagedUserInvite(user)
     handleResendManagedUserInvite(user)
   }
   }
 
 
+  const onResendGroupInviteClick = () => {
+    handleResendGroupInvite(user)
+  }
+
   const onDeleteUserClick = () => {
   const onDeleteUserClick = () => {
     openOffboardingModalForUser(user)
     openOffboardingModalForUser(user)
   }
   }
@@ -111,6 +157,14 @@ export default function ManagedUserDropdownButton({
           />
           />
         </Dropdown.Toggle>
         </Dropdown.Toggle>
         <Dropdown.Menu className="dropdown-menu-right managed-user-dropdown-menu">
         <Dropdown.Menu className="dropdown-menu-right managed-user-dropdown-menu">
+          {userPending ? (
+            <MenuItemButton onClick={onResendGroupInviteClick}>
+              {t('resend_group_invite')}
+              {isResendingGroupInvite ? (
+                <Icon type="spinner" spin style={{ marginLeft: '5px' }} />
+              ) : null}
+            </MenuItemButton>
+          ) : null}
           {userNotManaged ? (
           {userNotManaged ? (
             <MenuItemButton onClick={onResendManagedUserInviteClick}>
             <MenuItemButton onClick={onResendManagedUserInviteClick}>
               {t('resend_managed_user_invite')}
               {t('resend_managed_user_invite')}

+ 55 - 1
services/web/frontend/js/features/group-management/components/managed-users/managed-users-list-alert.tsx

@@ -29,7 +29,21 @@ export default function ManagedUsersListAlert({
           invitedUserEmail={invitedUserEmail}
           invitedUserEmail={invitedUserEmail}
         />
         />
       )
       )
-    case 'resendManagedUserInviteTooManyRequests':
+    case 'resendGroupInviteSuccess':
+      return (
+        <ResendGroupInviteSuccess
+          onDismiss={onDismiss}
+          invitedUserEmail={invitedUserEmail}
+        />
+      )
+    case 'resendGroupInviteFailed':
+      return (
+        <FailedToResendGroupInvite
+          onDismiss={onDismiss}
+          invitedUserEmail={invitedUserEmail}
+        />
+      )
+    case 'resendInviteTooManyRequests':
       return (
       return (
         <TooManyRequests
         <TooManyRequests
           onDismiss={onDismiss}
           onDismiss={onDismiss}
@@ -84,6 +98,46 @@ function FailedToResendManagedInvite({
   )
   )
 }
 }
 
 
+function ResendGroupInviteSuccess({
+  onDismiss,
+  invitedUserEmail,
+}: ManagedUsersListAlertComponentProps) {
+  return (
+    <AlertComponent bsStyle="success" onDismiss={onDismiss}>
+      <Trans
+        i18nKey="group_invite_has_been_sent_to_email"
+        values={{
+          email: invitedUserEmail,
+        }}
+        components={[
+          // eslint-disable-next-line react/jsx-key
+          <strong />,
+        ]}
+      />
+    </AlertComponent>
+  )
+}
+
+function FailedToResendGroupInvite({
+  onDismiss,
+  invitedUserEmail,
+}: ManagedUsersListAlertComponentProps) {
+  return (
+    <AlertComponent bsStyle="danger" onDismiss={onDismiss}>
+      <Trans
+        i18nKey="failed_to_send_group_invite_to_email"
+        values={{
+          email: invitedUserEmail,
+        }}
+        components={[
+          // eslint-disable-next-line react/jsx-key
+          <strong />,
+        ]}
+      />
+    </AlertComponent>
+  )
+}
+
 function TooManyRequests({
 function TooManyRequests({
   onDismiss,
   onDismiss,
   invitedUserEmail,
   invitedUserEmail,

+ 3 - 1
services/web/frontend/js/features/group-management/utils/types.ts

@@ -1,7 +1,9 @@
 export type ManagedUserAlertVariant =
 export type ManagedUserAlertVariant =
   | 'resendManagedUserInviteSuccess'
   | 'resendManagedUserInviteSuccess'
   | 'resendManagedUserInviteFailed'
   | 'resendManagedUserInviteFailed'
-  | 'resendManagedUserInviteTooManyRequests'
+  | 'resendGroupInviteSuccess'
+  | 'resendGroupInviteFailed'
+  | 'resendInviteTooManyRequests'
 
 
 export type ManagedUserAlert =
 export type ManagedUserAlert =
   | {
   | {

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

@@ -494,6 +494,7 @@
   "expiry": "Expiry Date",
   "expiry": "Expiry Date",
   "export_csv": "Export CSV",
   "export_csv": "Export CSV",
   "export_project_to_github": "Export Project to GitHub",
   "export_project_to_github": "Export Project to GitHub",
+  "failed_to_send_group_invite_to_email": "Failed to send Group invite to <0>__email__</0>. Please try again later.",
   "failed_to_send_managed_user_invite_to_email": "Failed to send Managed User invite to <0>__email__</0>. Please try again later.",
   "failed_to_send_managed_user_invite_to_email": "Failed to send Managed User invite to <0>__email__</0>. Please try again later.",
   "faq_change_plans_or_cancel_answer": "Yes, you can do this at any time via your subscription settings. You can change plans, switch between monthly and annual billing options, or cancel to downgrade to the free plan. When cancelling, your subscription will continue until the end of the billing period.  If your account temporarily does not have a subscription, the only change will be to the features available to you. Your projects will always be available on your account.",
   "faq_change_plans_or_cancel_answer": "Yes, you can do this at any time via your subscription settings. You can change plans, switch between monthly and annual billing options, or cancel to downgrade to the free plan. When cancelling, your subscription will continue until the end of the billing period.  If your account temporarily does not have a subscription, the only change will be to the features available to you. Your projects will always be available on your account.",
   "faq_change_plans_or_cancel_question": "Can I change plans or cancel later?",
   "faq_change_plans_or_cancel_question": "Can I change plans or cancel later?",
@@ -674,6 +675,7 @@
   "group_admins_get_access_to": "Group admins get access to",
   "group_admins_get_access_to": "Group admins get access to",
   "group_admins_get_access_to_info": "Special features available only on group plans.",
   "group_admins_get_access_to_info": "Special features available only on group plans.",
   "group_full": "This group is already full",
   "group_full": "This group is already full",
+  "group_invite_has_been_sent_to_email": "Group invite has been sent to <0>__email__</0>",
   "group_managed_by_group_administrator": "User accounts in this group are managed by the group administrator.",
   "group_managed_by_group_administrator": "User accounts in this group are managed by the group administrator.",
   "group_members_and_collaborators_get_access_to": "Group members and their project collaborators get access to",
   "group_members_and_collaborators_get_access_to": "Group members and their project collaborators get access to",
   "group_members_and_collaborators_get_access_to_info": "These features are available to group members and their collaborators (other Overleaf users invited to projects owned a group member).",
   "group_members_and_collaborators_get_access_to_info": "These features are available to group members and their collaborators (other Overleaf users invited to projects owned a group member).",
@@ -1391,6 +1393,7 @@
   "resend": "Resend",
   "resend": "Resend",
   "resend_confirmation_email": "Resend confirmation email",
   "resend_confirmation_email": "Resend confirmation email",
   "resend_email": "Resend email",
   "resend_email": "Resend email",
+  "resend_group_invite": "Resend group invite",
   "resend_managed_user_invite": "Resend managed user invite",
   "resend_managed_user_invite": "Resend managed user invite",
   "resending_confirmation_email": "Resending confirmation email",
   "resending_confirmation_email": "Resending confirmation email",
   "reset_password": "Reset Password",
   "reset_password": "Reset Password",