From a834e02cd559fc275b92abf318b70cf9dccba22f Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 23 Aug 2023 11:53:01 -0700 Subject: [PATCH] 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 --- .../app/src/Features/Email/EmailBuilder.js | 16 ++- .../Subscription/TeamInvitesController.js | 110 +++++++++++++----- .../UserMembership/UserMembershipRouter.js | 6 + .../web/frontend/extracted-translations.json | 3 + .../managed-user-dropdown-button.tsx | 60 +++++++++- .../managed-users-list-alert.tsx | 56 ++++++++- .../features/group-management/utils/types.ts | 4 +- services/web/locales/en.json | 3 + 8 files changed, 221 insertions(+), 37 deletions(-) diff --git a/services/web/app/src/Features/Email/EmailBuilder.js b/services/web/app/src/Features/Email/EmailBuilder.js index d95d37e335..84b79c78b2 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.js +++ b/services/web/app/src/Features/Email/EmailBuilder.js @@ -362,12 +362,16 @@ templates.verifyEmailToJoinTeam = ctaTemplate({ templates.verifyEmailToJoinManagedUsers = ctaTemplate({ subject(opts) { - return `You’ve been invited by ${_.escape( + return `${ + opts.reminder ? 'Reminder: ' : '' + }You’ve been invited by ${_.escape( _formatUserNameAndEmail(opts.inviter, 'a collaborator') )} to join an ${settings.appName} group subscription.` }, title(opts) { - return `You’ve been invited by ${_.escape( + return `${ + opts.reminder ? 'Reminder: ' : '' + }You’ve been invited by ${_.escape( _formatUserNameAndEmail(opts.inviter, 'a collaborator') )} to join an ${settings.appName} group subscription.` }, @@ -404,12 +408,16 @@ templates.verifyEmailToJoinManagedUsers = ctaTemplate({ templates.inviteNewUserToJoinManagedUsers = ctaTemplate({ subject(opts) { - return `You’ve been invited by ${_.escape( + return `${ + opts.reminder ? 'Reminder: ' : '' + }You’ve been invited by ${_.escape( _formatUserNameAndEmail(opts.inviter, 'a collaborator') )} to join an ${settings.appName} group subscription.` }, title(opts) { - return `You’ve been invited by ${_.escape( + return `${ + opts.reminder ? 'Reminder: ' : '' + }You’ve been invited by ${_.escape( _formatUserNameAndEmail(opts.inviter, 'a collaborator') )} to join an ${settings.appName} group subscription.` }, diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.js b/services/web/app/src/Features/Subscription/TeamInvitesController.js index 85a22ff7c0..9de69042ac 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.js +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.js @@ -1,5 +1,6 @@ const settings = require('@overleaf/settings') const logger = require('@overleaf/logger') +const OError = require('@overleaf/o-error') const TeamInvitesHandler = require('./TeamInvitesHandler') const SessionManager = require('../Authentication/SessionManager') const SubscriptionLocator = require('./SubscriptionLocator') @@ -9,8 +10,17 @@ const UserGetter = require('../User/UserGetter') const { expressify } = require('../../util/promises') const HttpErrorHandler = require('../Errors/HttpErrorHandler') const PermissionsManager = require('../Authorization/PermissionsManager') +const EmailHandler = require('../Email/EmailHandler') +const { RateLimiter } = require('../../infrastructure/RateLimiter') -function createInvite(req, res, next) { +const rateLimiters = { + resendGroupInvite: new RateLimiter('resend-group-invite', { + points: 1, + duration: 60 * 60, + }), +} + +async function createInvite(req, res, next) { const teamManagerId = SessionManager.getLoggedInUserId(req.session) const subscription = req.entity 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) { @@ -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 = { - createInvite, + createInvite: expressify(createInvite), viewInvite: expressify(viewInvite), acceptInvite: expressify(acceptInvite), revokeInvite, + resendInvite: expressify(resendInvite), } diff --git a/services/web/app/src/Features/UserMembership/UserMembershipRouter.js b/services/web/app/src/Features/UserMembership/UserMembershipRouter.js index 39b54aed9d..db2aef6a9b 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipRouter.js +++ b/services/web/app/src/Features/UserMembership/UserMembershipRouter.js @@ -30,6 +30,12 @@ module.exports = { RateLimiterMiddleware.rateLimit(rateLimiters.createTeamInvite), TeamInvitesController.createInvite ) + webRouter.post( + '/manage/groups/:id/resendInvite', + UserMembershipMiddleware.requireGroupManagementAccess, + RateLimiterMiddleware.rateLimit(rateLimiters.createTeamInvite), + TeamInvitesController.resendInvite + ) webRouter.delete( '/manage/groups/:id/user/:user_id', UserMembershipMiddleware.requireGroupManagementAccess, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index af539a19a6..fbe8793efb 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -310,6 +310,7 @@ "expires": "", "export_csv": "", "export_project_to_github": "", + "failed_to_send_group_invite_to_email": "", "failed_to_send_managed_user_invite_to_email": "", "fast": "", "faster_compiles_feedback_question": "", @@ -404,6 +405,7 @@ "go_to_pdf_location_in_code": "", "go_to_settings": "", "group_admin": "", + "group_invite_has_been_sent_to_email": "", "group_managed_by_group_administrator": "", "group_plan_tooltip": "", "group_plan_with_name_tooltip": "", @@ -850,6 +852,7 @@ "resend": "", "resend_confirmation_email": "", "resend_email": "", + "resend_group_invite": "", "resend_managed_user_invite": "", "resending_confirmation_email": "", "resolve": "", diff --git a/services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx index b431b0cd6c..a0df4ab5eb 100644 --- a/services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx +++ b/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 { useGroupMembersContext } from '../../context/group-members-context' -type ResendManagedUserInviteResponse = { +type resendInviteResponse = { success: boolean } @@ -40,11 +40,18 @@ export default function ManagedUserDropdownButton({ const { runAsync: runResendManagedUserInviteAsync, isLoading: isResendingManagedUserInvite, - } = useAsync() + } = useAsync() + + const { + runAsync: runResendGroupInviteAsync, + isLoading: isResendingGroupInvite, + } = useAsync() const userNotManaged = !user.isEntityAdmin && !user.invite && !user.enrollment?.managedBy + const userPending = user.invite + const handleResendManagedUserInvite = useCallback( async user => { try { @@ -64,7 +71,7 @@ export default function ManagedUserDropdownButton({ } catch (err) { if ((err as FetchError)?.response?.status === 429) { setManagedUserAlert({ - variant: 'resendManagedUserInviteTooManyRequests', + variant: 'resendInviteTooManyRequests', email: user.email, }) } else { @@ -80,10 +87,49 @@ export default function ManagedUserDropdownButton({ [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 = () => { handleResendManagedUserInvite(user) } + const onResendGroupInviteClick = () => { + handleResendGroupInvite(user) + } + const onDeleteUserClick = () => { openOffboardingModalForUser(user) } @@ -111,6 +157,14 @@ export default function ManagedUserDropdownButton({ /> + {userPending ? ( + + {t('resend_group_invite')} + {isResendingGroupInvite ? ( + + ) : null} + + ) : null} {userNotManaged ? ( {t('resend_managed_user_invite')} diff --git a/services/web/frontend/js/features/group-management/components/managed-users/managed-users-list-alert.tsx b/services/web/frontend/js/features/group-management/components/managed-users/managed-users-list-alert.tsx index 4673e6b5b1..277e780a16 100644 --- a/services/web/frontend/js/features/group-management/components/managed-users/managed-users-list-alert.tsx +++ b/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} /> ) - case 'resendManagedUserInviteTooManyRequests': + case 'resendGroupInviteSuccess': + return ( + + ) + case 'resendGroupInviteFailed': + return ( + + ) + case 'resendInviteTooManyRequests': return ( + , + ]} + /> + + ) +} + +function FailedToResendGroupInvite({ + onDismiss, + invitedUserEmail, +}: ManagedUsersListAlertComponentProps) { + return ( + + , + ]} + /> + + ) +} + function TooManyRequests({ onDismiss, invitedUserEmail, diff --git a/services/web/frontend/js/features/group-management/utils/types.ts b/services/web/frontend/js/features/group-management/utils/types.ts index f2817e6d5a..140d6f1de8 100644 --- a/services/web/frontend/js/features/group-management/utils/types.ts +++ b/services/web/frontend/js/features/group-management/utils/types.ts @@ -1,7 +1,9 @@ export type ManagedUserAlertVariant = | 'resendManagedUserInviteSuccess' | 'resendManagedUserInviteFailed' - | 'resendManagedUserInviteTooManyRequests' + | 'resendGroupInviteSuccess' + | 'resendGroupInviteFailed' + | 'resendInviteTooManyRequests' export type ManagedUserAlert = | { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 7180a700f7..1967f7dec7 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -494,6 +494,7 @@ "expiry": "Expiry Date", "export_csv": "Export CSV", "export_project_to_github": "Export Project to GitHub", + "failed_to_send_group_invite_to_email": "Failed to send Group invite to <0>__email__. Please try again later.", "failed_to_send_managed_user_invite_to_email": "Failed to send Managed User invite to <0>__email__. 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_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_info": "Special features available only on group plans.", "group_full": "This group is already full", + "group_invite_has_been_sent_to_email": "Group invite has been sent to <0>__email__", "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_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_confirmation_email": "Resend confirmation email", "resend_email": "Resend email", + "resend_group_invite": "Resend group invite", "resend_managed_user_invite": "Resend managed user invite", "resending_confirmation_email": "Resending confirmation email", "reset_password": "Reset Password",