Merge pull request #14311 from overleaf/mf-resend-surrender-email

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

GitOrigin-RevId: 2734ef3be31f77c309caec96e97411c9d48a8160
This commit is contained in:
M Fahru 2023-08-22 14:38:37 -07:00 committed by Copybot
parent 808fd2c0f9
commit 1e4028d05e
13 changed files with 366 additions and 31 deletions

View file

@ -443,14 +443,22 @@ templates.surrenderAccountForManagedUsers = ctaTemplate({
const toGroupName = opts.groupName ? ` to ${opts.groupName}` : ''
return `Youve been invited by ${admin} to transfer management of your ${settings.appName} account${toGroupName}`
return `${
opts.reminder ? 'Reminder: ' : ''
}Youve been invited by ${admin} to transfer management of your ${
settings.appName
} account${toGroupName}`
},
title(opts) {
const admin = _.escape(_formatUserNameAndEmail(opts.admin, 'an admin'))
const toGroupName = opts.groupName ? ` to ${opts.groupName}` : ''
return `Youve been invited by ${admin} to transfer management of your ${settings.appName} account${toGroupName}`
return `${
opts.reminder ? 'Reminder: ' : ''
}Youve been invited by ${admin} to transfer management of your ${
settings.appName
} account${toGroupName}`
},
message(opts, isPlainText) {
const admin = _.escape(_formatUserNameAndEmail(opts.admin, 'an admin'))

View file

@ -59,6 +59,7 @@
"all_projects": "",
"all_projects_will_be_transferred_immediately": "",
"also": "",
"an_email_has_already_been_sent_to": "",
"an_error_occurred_when_verifying_the_coupon_code": "",
"anonymous": "",
"anyone_with_link_can_edit": "",
@ -304,6 +305,7 @@
"expires": "",
"export_csv": "",
"export_project_to_github": "",
"failed_to_send_managed_user_invite_to_email": "",
"fast": "",
"faster_compiles_feedback_question": "",
"faster_compiles_feedback_seems_faster": "",
@ -589,6 +591,7 @@
"manage_sessions": "",
"manage_subscription": "",
"managed": "",
"managed_user_invite_has_been_sent_to_email": "",
"managed_users": "",
"managed_users_explanation": "",
"managed_users_terms": "",
@ -841,6 +844,7 @@
"resend": "",
"resend_confirmation_email": "",
"resend_email": "",
"resend_managed_user_invite": "",
"resending_confirmation_email": "",
"resolve": "",
"resolved_comments": "",

View file

@ -1,21 +1,88 @@
import {
useState,
type ComponentProps,
useCallback,
type Dispatch,
type SetStateAction,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Dropdown, MenuItem } from 'react-bootstrap'
import { User } from '../../../../../../types/group-management/user'
import ControlledDropdown from '../../../../shared/components/controlled-dropdown'
import { useTranslation } from 'react-i18next'
import MenuItemButton from '../../../project-list/components/dropdown/menu-item-button'
import useAsync from '../../../../shared/hooks/use-async'
import {
type FetchError,
postJSON,
} from '../../../../infrastructure/fetch-json'
import Icon from '../../../../shared/components/icon'
import { ManagedUserAlert } from '../../utils/types'
import { useGroupMembersContext } from '../../context/group-members-context'
type ResendManagedUserInviteResponse = {
success: boolean
}
type ManagedUserDropdownButtonProps = {
user: User
openOffboardingModalForUser: (user: User) => void
groupId: string
setManagedUserAlert: Dispatch<SetStateAction<ManagedUserAlert>>
}
export default function ManagedUserDropdownButton({
user,
openOffboardingModalForUser,
groupId,
setManagedUserAlert,
}: ManagedUserDropdownButtonProps) {
const { t } = useTranslation()
const { removeMember } = useGroupMembersContext()
const [isOpened, setIsOpened] = useState(false)
const {
runAsync: runResendManagedUserInviteAsync,
isLoading: isResendingManagedUserInvite,
} = useAsync<ResendManagedUserInviteResponse>()
const userNotManaged =
!user.isEntityAdmin && !user.invite && !user.enrollment?.managedBy
const handleResendManagedUserInvite = useCallback(
async user => {
try {
const result = await runResendManagedUserInviteAsync(
postJSON(
`/manage/groups/${groupId}/resendManagedUserInvite/${user._id}`
)
)
if (result.success) {
setManagedUserAlert({
variant: 'resendManagedUserInviteSuccess',
email: user.email,
})
setIsOpened(false)
}
} catch (err) {
if ((err as FetchError)?.response?.status === 429) {
setManagedUserAlert({
variant: 'resendManagedUserInviteTooManyRequests',
email: user.email,
})
} else {
setManagedUserAlert({
variant: 'resendManagedUserInviteFailed',
email: user.email,
})
}
setIsOpened(false)
}
},
[setManagedUserAlert, groupId, runResendManagedUserInviteAsync]
)
const onResendManagedUserInviteClick = () => {
handleResendManagedUserInvite(user)
}
const onDeleteUserClick = () => {
openOffboardingModalForUser(user)
@ -27,7 +94,11 @@ export default function ManagedUserDropdownButton({
return (
<span className="managed-user-actions">
<ControlledDropdown id={`managed-user-dropdown-${user.email}`}>
<Dropdown
id={`managed-user-dropdown-${user.email}`}
open={isOpened}
onToggle={open => setIsOpened(open)}
>
<Dropdown.Toggle
bsStyle={null}
className="btn btn-link action-btn"
@ -39,7 +110,15 @@ export default function ManagedUserDropdownButton({
aria-label={t('actions')}
/>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right managed-users-dropdown-menu">
<Dropdown.Menu className="dropdown-menu-right managed-user-dropdown-menu">
{userNotManaged ? (
<MenuItemButton onClick={onResendManagedUserInviteClick}>
{t('resend_managed_user_invite')}
{isResendingManagedUserInvite ? (
<Icon type="spinner" spin style={{ marginLeft: '5px' }} />
) : null}
</MenuItemButton>
) : null}
{user.enrollment ? (
<MenuItemButton
className="delete-user-action"
@ -62,7 +141,27 @@ export default function ManagedUserDropdownButton({
</MenuItemButton>
)}
</Dropdown.Menu>
</ControlledDropdown>
</Dropdown>
</span>
)
}
function MenuItemButton({
children,
onClick,
className,
...buttonProps
}: ComponentProps<'button'>) {
return (
<li role="presentation" className={className}>
<button
className="managed-user-menu-item-button"
role="menuitem"
onClick={onClick}
{...buttonProps}
>
{children}
</button>
</li>
)
}

View file

@ -1,22 +1,27 @@
import moment from 'moment'
import { useCallback } from 'react'
import { type Dispatch, type SetStateAction, useCallback } from 'react'
import { Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
import Badge from '../../../../shared/components/badge'
import { useGroupMembersContext } from '../../context/group-members-context'
import ManagedUserDropdownButton from './managed-user-dropdown-button'
import Tooltip from '../../../../shared/components/tooltip'
import type { ManagedUserAlert } from '../../utils/types'
import { useGroupMembersContext } from '../../context/group-members-context'
import ManagedUserStatus from './managed-user-status'
import ManagedUserDropdownButton from './managed-user-dropdown-button'
type ManagedUserRowProps = {
user: User
openOffboardingModalForUser: (user: User) => void
groupId: string
setManagedUserAlert: Dispatch<SetStateAction<ManagedUserAlert>>
}
export default function ManagedUserRow({
user,
openOffboardingModalForUser,
setManagedUserAlert,
groupId,
}: ManagedUserRowProps) {
const { t } = useTranslation()
const { selectedUsers, selectUser, unselectUser } = useGroupMembersContext()
@ -101,6 +106,8 @@ export default function ManagedUserRow({
<ManagedUserDropdownButton
user={user}
openOffboardingModalForUser={openOffboardingModalForUser}
setManagedUserAlert={setManagedUserAlert}
groupId={groupId}
/>
</div>
</Col>

View file

@ -0,0 +1,139 @@
import { type PropsWithChildren, useState } from 'react'
import { Alert, type AlertProps } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import type { ManagedUserAlertVariant } from '../../utils/types'
type ManagedUsersListAlertProps = {
variant: ManagedUserAlertVariant
invitedUserEmail?: string
onDismiss: () => void
}
export default function ManagedUsersListAlert({
variant,
invitedUserEmail,
onDismiss,
}: ManagedUsersListAlertProps) {
switch (variant) {
case 'resendManagedUserInviteSuccess':
return (
<ResendManagedUserInviteSuccess
onDismiss={onDismiss}
invitedUserEmail={invitedUserEmail}
/>
)
case 'resendManagedUserInviteFailed':
return (
<FailedToResendManagedInvite
onDismiss={onDismiss}
invitedUserEmail={invitedUserEmail}
/>
)
case 'resendManagedUserInviteTooManyRequests':
return (
<TooManyRequests
onDismiss={onDismiss}
invitedUserEmail={invitedUserEmail}
/>
)
}
}
type ManagedUsersListAlertComponentProps = {
onDismiss: () => void
invitedUserEmail?: string
}
function ResendManagedUserInviteSuccess({
onDismiss,
invitedUserEmail,
}: ManagedUsersListAlertComponentProps) {
return (
<AlertComponent bsStyle="success" onDismiss={onDismiss}>
<Trans
i18nKey="managed_user_invite_has_been_sent_to_email"
values={{
email: invitedUserEmail,
}}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</AlertComponent>
)
}
function FailedToResendManagedInvite({
onDismiss,
invitedUserEmail,
}: ManagedUsersListAlertComponentProps) {
return (
<AlertComponent bsStyle="danger" onDismiss={onDismiss}>
<Trans
i18nKey="failed_to_send_managed_user_invite_to_email"
values={{
email: invitedUserEmail,
}}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</AlertComponent>
)
}
function TooManyRequests({
onDismiss,
invitedUserEmail,
}: ManagedUsersListAlertComponentProps) {
return (
<AlertComponent bsStyle="danger" onDismiss={onDismiss}>
<Trans
i18nKey="an_email_has_already_been_sent_to"
values={{
email: invitedUserEmail,
}}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</AlertComponent>
)
}
type AlertComponentProps = PropsWithChildren<{
bsStyle: AlertProps['bsStyle']
onDismiss: AlertProps['onDismiss']
}>
function AlertComponent({ bsStyle, onDismiss, children }: AlertComponentProps) {
const [show, setShow] = useState(true)
const { t } = useTranslation()
const handleDismiss = () => {
if (onDismiss) {
onDismiss()
}
setShow(false)
}
if (!show) {
return null
}
return (
<Alert bsStyle={bsStyle} className="managed-users-list-alert">
<span>{children}</span>
<div className="managed-users-list-alert-close">
<button type="button" className="close" onClick={handleDismiss}>
<span aria-hidden="true">&times;</span>
<span className="sr-only">{t('close')}</span>
</button>
</div>
</Alert>
)
}

View file

@ -1,11 +1,13 @@
import { useState } from 'react'
import { Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
import Tooltip from '../../../../shared/components/tooltip'
import { useGroupMembersContext } from '../../context/group-members-context'
import type { ManagedUserAlert } from '../../utils/types'
import ManagedUserRow from './managed-user-row'
import OffboardManagedUserModal from './offboard-managed-user-modal'
import { useState } from 'react'
import ManagedUsersListAlert from './managed-users-list-alert'
type ManagedUsersListProps = {
handleSelectAllClick: (e: any) => void
@ -20,10 +22,19 @@ export default function ManagedUsersList({
const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
undefined
)
const [managedUserAlert, setManagedUserAlert] =
useState<ManagedUserAlert>(undefined)
const { selectedUsers, users } = useGroupMembersContext()
return (
<div>
{managedUserAlert && (
<ManagedUsersListAlert
variant={managedUserAlert.variant}
invitedUserEmail={managedUserAlert.email}
onDismiss={() => setManagedUserAlert(undefined)}
/>
)}
<ul className="list-unstyled structured-list managed-users-list">
<li className="container-fluid">
<Row id="managed-users-list-headers">
@ -76,6 +87,8 @@ export default function ManagedUsersList({
key={user.email}
user={user}
openOffboardingModalForUser={setUserToOffboard}
setManagedUserAlert={setManagedUserAlert}
groupId={groupId}
/>
))}
</ul>

View file

@ -0,0 +1,11 @@
export type ManagedUserAlertVariant =
| 'resendManagedUserInviteSuccess'
| 'resendManagedUserInviteFailed'
| 'resendManagedUserInviteTooManyRequests'
export type ManagedUserAlert =
| {
variant: ManagedUserAlertVariant
email?: string
}
| undefined

View file

@ -437,7 +437,7 @@ ul.structured-list {
overflow: hidden;
overflow-y: auto;
-ms-overflow-style: -ms-autohiding-scrollbar;
li {
> li {
border-bottom: 1px solid @structured-list-border-color;
padding: (@line-height-computed / 4) 0;

View file

@ -20,6 +20,7 @@
.managed-user-row {
overflow-wrap: break-word;
}
.managed-user-security {
display: flex;
justify-content: space-between;
@ -30,15 +31,45 @@
padding-left: 1em;
padding-right: 1em;
}
li > button {
&:hover {
background-color: @gray-lightest;
.managed-user-dropdown-menu {
width: 300px;
li > button {
&:hover {
background-color: @gray-lightest;
}
}
}
.delete-user-action {
button {
color: @red;
.delete-user-action {
button {
color: @red;
}
}
}
}
.managed-user-menu-item-button {
padding: 12px 20px;
position: relative;
width: 100%;
border: none;
box-shadow: none;
background: inherit;
color: @ol-blue-gray-5;
text-align: left;
&[disabled] {
background-color: @gray-lighter;
}
}
}
.managed-users-list-alert {
display: flex;
justify-content: space-between;
.managed-users-list-alert-close {
padding-left: @padding-sm;
text-align: right;
width: 10%;
@media (min-width: @screen-sm-min) {
width: auto;
}
}
}

View file

@ -95,6 +95,7 @@
"also": "Also",
"also_available_as_on_premises": "Also available as On-Premises",
"alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__</0>. Please wait and try again later.",
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
"and": "and",
"annual": "Annual",
@ -484,6 +485,7 @@
"expiry": "Expiry Date",
"export_csv": "Export CSV",
"export_project_to_github": "Export Project to GitHub",
"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_question": "Can I change plans or cancel later?",
"faq_do_collab_need_on_paid_plan_answer": "No, they can be on any plan, including the free plan. If you are on a premium plan, some premium features will be available to your collaborators in projects that you have created, even if those collaborators are on the free plan. For more information, read about <0>account and subscriptions</0> and <1>how premium features work</1>.",
@ -968,6 +970,7 @@
"manage_sessions": "Manage Your Sessions",
"manage_subscription": "Manage Subscription",
"managed": "Managed",
"managed_user_invite_has_been_sent_to_email": "Managed User invite has been sent to <0>__email__</0>",
"managed_users": "Managed Users",
"managed_users_explanation": "Managed Users ensures you stay in control of your organizations projects and who owns them. <0>Read more about Managed Users.</0>",
"managed_users_terms": "To use the Managed Users feature, you must agree to the latest version of our customer terms at <0>__link__</0> on behalf of your organization by selecting \"I agree\" below. These terms will then apply to your organizations use of Overleaf in place of any previously agreed Overleaf terms, except where we have a signed agreement in place with you, in which case that signed agreement will continue to govern. Please keep a copy for your records",
@ -1376,6 +1379,7 @@
"resend": "Resend",
"resend_confirmation_email": "Resend confirmation email",
"resend_email": "Resend email",
"resend_managed_user_invite": "Resend Managed User invite",
"resending_confirmation_email": "Resending confirmation email",
"reset_password": "Reset Password",
"reset_your_password": "Reset your password",
@ -1809,6 +1813,7 @@
"user_already_added": "User already added",
"user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.",
"user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as Twitter or Google), please <0>reset your password</0> and try again.",
"user_is_not_part_of_group": "User is not part of group",
"user_management": "User management",
"user_management_info": "Group plan admins have access to an admin panel where users can be added and removed easily. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).",
"user_not_found": "User not found",

View file

@ -64,7 +64,7 @@ describe('group members, with managed users', function () {
cy.get('small').contains('You have added 3 of 10 available members')
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('> li:nth-child(2)').within(() => {
cy.contains('john.doe@test.com')
cy.contains('John Doe')
cy.contains('15th Jan 2023')
@ -74,7 +74,7 @@ describe('group members, with managed users', function () {
cy.get(`.security-state-invite-pending`).should('exist')
})
cy.get('li:nth-child(3)').within(() => {
cy.get('> li:nth-child(3)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
@ -82,7 +82,7 @@ describe('group members, with managed users', function () {
cy.get('i[aria-label="Not managed"]').should('exist')
})
cy.get('li:nth-child(4)').within(() => {
cy.get('> li:nth-child(4)').within(() => {
cy.contains('claire.jennings@test.com')
cy.contains('Claire Jennings')
cy.contains('3rd Jan 2023')
@ -107,7 +107,7 @@ describe('group members, with managed users', function () {
cy.get('.add-more-members-form button').click()
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(5)').within(() => {
cy.get('> li:nth-child(5)').within(() => {
cy.contains('someone.else@test.com')
cy.contains('N/A')
cy.get(`[aria-label="Pending invite"]`)
@ -134,7 +134,7 @@ describe('group members, with managed users', function () {
it('checks the select all checkbox', function () {
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('> li:nth-child(2)').within(() => {
cy.get('.select-item').should('not.be.checked')
})
cy.get('li:nth-child(3)').within(() => {
@ -145,7 +145,7 @@ describe('group members, with managed users', function () {
cy.get('.select-all').click()
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('> li:nth-child(2)').within(() => {
cy.get('.select-item').should('be.checked')
})
cy.get('li:nth-child(3)').within(() => {
@ -160,7 +160,7 @@ describe('group members, with managed users', function () {
})
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('> li:nth-child(2)').within(() => {
cy.get('.select-item').check()
})
})
@ -169,7 +169,7 @@ describe('group members, with managed users', function () {
cy.get('small').contains('You have added 2 of 10 available members')
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('> li:nth-child(2)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
@ -184,7 +184,7 @@ describe('group members, with managed users', function () {
cy.get('ul.managed-users-list').within(() => {
// Select 'Claire Jennings', a managed user
cy.get('li:nth-child(4)').within(() => {
cy.get('> li:nth-child(4)').within(() => {
cy.get('.select-item').check()
})
})
@ -201,7 +201,7 @@ describe('group members, with managed users', function () {
cy.get('ul.managed-users-list').within(() => {
// Select 'Claire Jennings', a managed user
cy.get('li:nth-child(4)').within(() => {
cy.get('> li:nth-child(4)').within(() => {
cy.get('.select-item').check()
})
// Select another user
@ -221,7 +221,7 @@ describe('group members, with managed users', function () {
})
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('> li:nth-child(2)').within(() => {
cy.get('.select-item').check()
})
})

View file

@ -3,6 +3,8 @@ import sinon from 'sinon'
import { GroupMembersProvider } from '../../../../../../frontend/js/features/group-management/context/group-members-context'
describe('ManagedUserDropdownButton', function () {
const subscriptionId = '123abc'
describe('with managed user', function () {
const user = {
_id: 'some-user',
@ -28,6 +30,8 @@ describe('ManagedUserDropdownButton', function () {
<ManagedUserDropdownButton
user={user}
openOffboardingModalForUser={sinon.stub()}
groupId={subscriptionId}
setManagedUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
@ -71,6 +75,8 @@ describe('ManagedUserDropdownButton', function () {
<ManagedUserDropdownButton
user={user}
openOffboardingModalForUser={sinon.stub()}
groupId={subscriptionId}
setManagedUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
@ -114,6 +120,8 @@ describe('ManagedUserDropdownButton', function () {
<ManagedUserDropdownButton
user={user}
openOffboardingModalForUser={sinon.stub()}
groupId={subscriptionId}
setManagedUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)

View file

@ -4,6 +4,8 @@ import { GroupMembersProvider } from '../../../../../../frontend/js/features/gro
import { User } from '../../../../../../types/group-management/user'
describe('ManagedUserRow', function () {
const subscriptionId = '123abc'
describe('with an ordinary user', function () {
let user: User
@ -27,6 +29,8 @@ describe('ManagedUserRow', function () {
<ManagedUserRow
user={user}
openOffboardingModalForUser={sinon.stub()}
groupId={subscriptionId}
setManagedUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
@ -75,6 +79,8 @@ describe('ManagedUserRow', function () {
<ManagedUserRow
user={user}
openOffboardingModalForUser={sinon.stub()}
groupId={subscriptionId}
setManagedUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
@ -108,6 +114,8 @@ describe('ManagedUserRow', function () {
<ManagedUserRow
user={user}
openOffboardingModalForUser={sinon.stub()}
groupId={subscriptionId}
setManagedUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
@ -141,6 +149,8 @@ describe('ManagedUserRow', function () {
<ManagedUserRow
user={user}
openOffboardingModalForUser={sinon.stub()}
groupId={subscriptionId}
setManagedUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)