Merge pull request #13604 from overleaf/jk-managed-users-offboarding-ui

[web] Managed Users offboarding UI

GitOrigin-RevId: ee4a1ae7cdb0022839ef232836ef6933443400fc
This commit is contained in:
Tim Down 2023-07-13 09:50:43 +01:00 committed by Copybot
parent be7fd54257
commit 49eafa2712
17 changed files with 508 additions and 84 deletions

View file

@ -15,7 +15,7 @@ module.exports = {
}
async function transferOwnership(projectId, newOwnerId, options = {}) {
const { allowTransferToNonCollaborators, sessionUserId } = options
const { allowTransferToNonCollaborators, sessionUserId, skipEmails } = options
// Fetch project and user
const [project, newOwner] = await Promise.all([
@ -58,7 +58,9 @@ async function transferOwnership(projectId, newOwnerId, options = {}) {
// Send confirmation emails
const previousOwner = await UserGetter.promises.getUser(previousOwnerId)
await _sendEmails(project, previousOwner, newOwner)
if (!skipEmails) {
await _sendEmails(project, previousOwner, newOwner)
}
}
async function _getProject(projectId) {

View file

@ -498,6 +498,30 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
},
})
templates.managedUserOffboarded = ctaTemplate({
subject() {
return `Your account has been deleted - ${settings.appName}`
},
title() {
return `Your account has been deleted`
},
message() {
return [
'Your group administrator has deleted your account. Contact your administrator to learn more about it.',
'You can create a new account if you want to use a different email address.',
]
},
secondaryMessage() {
return []
},
ctaText() {
return 'Create a new account'
},
ctaURL() {
return `${settings.siteUrl}/register`
},
})
templates.securityAlert = NoCTAEmailTemplate({
subject(opts) {
return `Overleaf security note: ${opts.action}`

View file

@ -7,12 +7,20 @@ function getAllTags(userId, callback) {
Tag.find({ user_id: userId }, callback)
}
function createTag(userId, name, color, callback) {
function createTag(userId, name, color, options, callback) {
if (typeof options === 'function') {
callback = options
options = {}
}
if (!callback) {
callback = function () {}
}
if (name.length > MAX_TAG_LENGTH) {
return callback(new Error('Exceeded max tag length'))
if (options.truncate) {
name = name.slice(0, MAX_TAG_LENGTH)
} else {
return callback(new Error('Exceeded max tag length'))
}
}
Tag.create({ user_id: userId, name, color }, function (err, tag) {
// on duplicate key error return existing tag

View file

@ -10,6 +10,7 @@
"about_to_delete_tag": "",
"about_to_delete_the_following_project": "",
"about_to_delete_the_following_projects": "",
"about_to_delete_user_preamble": "",
"about_to_enable_managed_users": "",
"about_to_leave_projects": "",
"about_to_trash_projects": "",
@ -53,6 +54,7 @@
"all_premium_features": "",
"all_premium_features_including": "",
"all_projects": "",
"all_projects_will_be_transferred_immediately": "",
"also": "",
"an_error_occurred_when_verifying_the_coupon_code": "",
"anonymous": "",
@ -132,6 +134,7 @@
"checking_dropbox_status": "",
"checking_project_github_status": "",
"choose_a_custom_color": "",
"choose_from_group_members": "",
"clear_cached_files": "",
"clear_search": "",
"click_here_to_view_sl_in_lng": "",
@ -163,6 +166,7 @@
"confirm": "",
"confirm_affiliation": "",
"confirm_affiliation_to_relink_dropbox": "",
"confirm_delete_user_type_email_address": "",
"confirm_new_password": "",
"confirm_primary_email_change": "",
"conflicting_paths_found": "",
@ -537,6 +541,7 @@
"layout_processing": "",
"learn_more": "",
"learn_more_about_link_sharing": "",
"learn_more_about_managed_users": "",
"leave": "",
"leave_any_group_subscriptions": "",
"leave_group": "",
@ -887,6 +892,7 @@
"see_changes_in_your_documents_live": "",
"select_a_file": "",
"select_a_file_figure_modal": "",
"select_a_new_owner_for_projects": "",
"select_a_payment_method": "",
"select_a_project": "",
"select_a_project_figure_modal": "",
@ -1006,9 +1012,12 @@
"thanks_settings_updated": "",
"the_following_files_already_exist_in_this_project": "",
"the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
"their_projects_will_be_transferred_to_another_user": "",
"then_x_price_per_month": "",
"then_x_price_per_year": "",
"there_are_lots_of_options_to_edit_and_customize_your_figures": "",
"they_lose_access_to_account": "",
"this_action_cannot_be_reversed": "",
"this_action_cannot_be_undone": "",
"this_address_will_be_shown_on_the_invoice": "",
"this_field_is_required": "",
@ -1070,6 +1079,8 @@
"transfer_management_of_your_account": "",
"transfer_management_of_your_account_to_x": "",
"transfer_management_resolve_following_issues": "",
"transfer_this_users_projects": "",
"transfer_this_users_projects_description": "",
"transferring": "",
"trash": "",
"trash_projects": "",
@ -1187,6 +1198,7 @@
"you_have_added_x_of_group_size_y": "",
"you_have_been_invited_to_transfer_management_of_your_account": "",
"you_have_been_invited_to_transfer_management_of_your_account_to": "",
"you_will_be_able_to_reassign_subscription": "",
"your_affiliation_is_confirmed": "",
"your_browser_does_not_support_this_feature": "",
"your_git_access_info": "",

View file

@ -203,6 +203,7 @@ export default function GroupMembers() {
users={users}
selectUser={selectUser}
unselectUser={unselectUser}
groupId={groupId}
/>
) : (
<GroupMembersList

View file

@ -2,39 +2,52 @@ 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'
type ManagedUserDropdownButtonProps = {
user: User
openOffboardingModalForUser: (user: User) => void
}
export default function ManagedUserDropdownButton({
user,
openOffboardingModalForUser,
}: ManagedUserDropdownButtonProps) {
const { t } = useTranslation()
const onDeleteUserClick = () => {
openOffboardingModalForUser(user)
}
return (
<ControlledDropdown id={`managed-user-dropdown-${user._id}`}>
<Dropdown.Toggle
bsStyle={null}
className="btn btn-link action-btn"
noCaret
>
<i
className="fa fa-ellipsis-v"
aria-hidden="true"
aria-label={t('actions')}
/>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
{user.enrollment ? (
<MenuItem className="delete-user-action">{t('delete_user')}</MenuItem>
) : (
<>
<>
<ControlledDropdown id={`managed-user-dropdown-${user._id}`}>
<Dropdown.Toggle
bsStyle={null}
className="btn btn-link action-btn"
noCaret
>
<i
className="fa fa-ellipsis-v"
aria-hidden="true"
aria-label={t('actions')}
/>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
{user.enrollment ? (
<MenuItemButton
className="delete-user-action"
onClick={onDeleteUserClick}
>
{t('delete_user')}
</MenuItemButton>
) : (
<MenuItem className="no-actions-available">
<span className="text-muted">{t('no_actions')}</span>
</MenuItem>
</>
)}
</Dropdown.Menu>
</ControlledDropdown>
)}
</Dropdown.Menu>
</ControlledDropdown>
</>
)
}

View file

@ -13,6 +13,7 @@ type ManagedUserRowProps = {
selectUser: (user: User) => void
unselectUser: (user: User) => void
selected: boolean
openOffboardingModalForUser: (user: User) => void
}
export default function ManagedUserRow({
@ -20,6 +21,7 @@ export default function ManagedUserRow({
selectUser,
unselectUser,
selected,
openOffboardingModalForUser,
}: ManagedUserRowProps) {
const { t } = useTranslation()
@ -86,7 +88,9 @@ export default function ManagedUserRow({
</span>
</Col>
<Col xs={2}>
{user.first_name} {user.last_name}
<span>
{user.first_name} {user.last_name}
</span>
</Col>
<Col xs={2}>
{user.last_active_at
@ -97,7 +101,10 @@ export default function ManagedUserRow({
<span className="pull-right">
<ManagedUserStatus user={user} />
<span className="managed-user-actions">
<ManagedUserDropdownButton user={user} />
<ManagedUserDropdownButton
user={user}
openOffboardingModalForUser={openOffboardingModalForUser}
/>
</span>
</span>
</Col>

View file

@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
import Tooltip from '../../../../shared/components/tooltip'
import ManagedUserRow from './managed-user-row'
import OffboardManagedUserModal from './offboard-managed-user-modal'
import { useState } from 'react'
type ManagedUsersListProps = {
handleSelectAllClick: (e: any) => void
@ -10,6 +12,7 @@ type ManagedUsersListProps = {
users: User[]
selectUser: (user: User) => void
unselectUser: (user: User) => void
groupId: string
}
export default function ManagedUsersList({
@ -18,65 +21,81 @@ export default function ManagedUsersList({
users,
selectUser,
unselectUser,
groupId,
}: ManagedUsersListProps) {
const { t } = useTranslation()
const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
undefined
)
return (
<ul className="list-unstyled structured-list managed-users-list">
<li className="container-fluid">
<Row id="managed-users-list-headers">
<Col xs={6}>
<label htmlFor="select-all" className="sr-only">
{t('select_all')}
</label>
<input
className="select-all"
id="select-all"
type="checkbox"
onChange={handleSelectAllClick}
checked={selectedUsers.length === users.length}
/>
<span className="header">{t('email')}</span>
</Col>
<Col xs={2}>
<span className="header">{t('name')}</span>
</Col>
<Col xs={2}>
<Tooltip
id="last-active-tooltip"
description={t('last_active_description')}
overlayProps={{
placement: 'left',
}}
>
<span className="header">
{t('last_active')}
<sup>(?)</sup>
</span>
</Tooltip>
</Col>
<Col xs={2}>
<span className="header">{t('security')}</span>
</Col>
</Row>
</li>
{users.length === 0 && (
<li>
<Row>
<Col md={12} className="text-centered">
<small>{t('no_members')}</small>
<div>
<ul className="list-unstyled structured-list managed-users-list">
<li className="container-fluid">
<Row id="managed-users-list-headers">
<Col xs={6}>
<label htmlFor="select-all" className="sr-only">
{t('select_all')}
</label>
<input
className="select-all"
id="select-all"
type="checkbox"
onChange={handleSelectAllClick}
checked={selectedUsers.length === users.length}
/>
<span className="header">{t('email')}</span>
</Col>
<Col xs={2}>
<span className="header">{t('name')}</span>
</Col>
<Col xs={2}>
<Tooltip
id="last-active-tooltip"
description={t('last_active_description')}
overlayProps={{
placement: 'left',
}}
>
<span className="header">
{t('last_active')}
<sup>(?)</sup>
</span>
</Tooltip>
</Col>
<Col xs={2}>
<span className="header">{t('security')}</span>
</Col>
</Row>
</li>
)}
{users.map((user: any) => (
<ManagedUserRow
key={user.email}
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selectedUsers.includes(user)}
{users.length === 0 && (
<li>
<Row>
<Col md={12} className="text-centered">
<small>{t('no_members')}</small>
</Col>
</Row>
</li>
)}
{users.map((user: any) => (
<ManagedUserRow
key={user.email}
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selectedUsers.includes(user)}
openOffboardingModalForUser={setUserToOffboard}
/>
))}
</ul>
{userToOffboard && (
<OffboardManagedUserModal
user={userToOffboard}
groupId={groupId}
allMembers={users}
onClose={() => setUserToOffboard(undefined)}
/>
))}
</ul>
)}
</div>
)
}

View file

@ -0,0 +1,164 @@
import { User } from '../../../../../../types/group-management/user'
import {
Alert,
Button,
ControlLabel,
Form,
FormControl,
FormGroup,
Modal,
} from 'react-bootstrap'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import Icon from '../../../../shared/components/icon'
import { useState } from 'react'
import useAsync from '../../../../shared/hooks/use-async'
import { useTranslation } from 'react-i18next'
import { useLocation } from '../../../../shared/hooks/use-location'
import { FetchError, postJSON } from '../../../../infrastructure/fetch-json'
type OffboardManagedUserModalProps = {
user: User
allMembers: User[]
groupId: string
onClose: () => void
}
export default function OffboardManagedUserModal({
user,
allMembers,
groupId,
onClose,
}: OffboardManagedUserModalProps) {
const { t } = useTranslation()
const location = useLocation()
const [selectedRecipientId, setSelectedRecipientId] = useState<string>()
const [suppliedEmail, setSuppliedEmail] = useState<string>()
const [error, setError] = useState<string>()
const { isLoading, isSuccess, runAsync } = useAsync()
const otherMembers = allMembers.filter(u => u._id !== user._id && !u.invite)
const userFullName = user.last_name
? `${user.first_name || ''} ${user.last_name || ''}`
: user.first_name
const shouldEnableDeleteUserButton =
suppliedEmail === user.email && !!selectedRecipientId
const handleDeleteUserSubmit = (event: any) => {
event.preventDefault()
runAsync(
postJSON(`/manage/groups/${groupId}/offboardManagedUser/${user._id}`, {
body: {
verificationEmail: suppliedEmail,
transferProjectsToUserId: selectedRecipientId,
},
})
.then(() => {
location.reload()
})
.catch(err => {
console.error(err)
setError(
err instanceof FetchError ? err.getUserFacingMessage() : err.message
)
})
)
}
return (
<AccessibleModal id={`delete-user-modal-${user._id}`} show onHide={onClose}>
<Form id="delete-user-form" onSubmit={handleDeleteUserSubmit}>
<Modal.Header>
<Modal.Title>{t('delete_user')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
{t('about_to_delete_user_preamble', {
userName: userFullName,
userEmail: user.email,
})}
</p>
<ul>
<li>{t('they_lose_access_to_account')}</li>
<li>{t('their_projects_will_be_transferred_to_another_user')}</li>
<li>{t('you_will_be_able_to_reassign_subscription')}</li>
</ul>
<p>
<span>{t('this_action_cannot_be_reversed')}</span>
&nbsp;
<a href="/learn/how-to/Managed_Users">
{t('learn_more_about_managed_users')}
</a>
</p>
<strong>{t('transfer_this_users_projects')}</strong>
<p>{t('transfer_this_users_projects_description')}</p>
<FormGroup>
<ControlLabel htmlFor="recipient-select-input">
{t('select_a_new_owner_for_projects')}
</ControlLabel>
<FormControl
id="recipient-select-input"
className="form-control"
componentClass="select"
aria-label={t('select_user')}
required
placeholder={t('choose_from_group_members')}
value={selectedRecipientId || ''}
onChange={(e: React.ChangeEvent<HTMLFormElement & FormControl>) =>
setSelectedRecipientId(e.target.value)
}
>
<option hidden disabled value="">
{t('choose_from_group_members')}
</option>
{otherMembers.map(member => (
<option value={member._id} key={member.email}>
{member.email}
</option>
))}
</FormControl>
</FormGroup>
<p>
<span>{t('all_projects_will_be_transferred_immediately')}</span>
</p>
<FormGroup>
<ControlLabel htmlFor="supplied-email-input">
{t('confirm_delete_user_type_email_address', {
userName: userFullName,
})}
</ControlLabel>
<FormControl
id="supplied-email-input"
type="email"
aria-label={t('email')}
onChange={(e: React.ChangeEvent<HTMLFormElement & FormControl>) =>
setSuppliedEmail(e.target.value)
}
/>
</FormGroup>
{error && <Alert bsStyle="danger">{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<FormGroup>
<Button onClick={onClose}>{t('cancel')}</Button>
<Button
type="submit"
bsStyle="danger"
disabled={isLoading || isSuccess || !shouldEnableDeleteUserButton}
>
{isLoading ? (
<>
<Icon type="refresh" fw spin /> {t('deleting')}
</>
) : (
t('delete_user')
)}
</Button>
</FormGroup>
</Modal.Footer>
</Form>
</AccessibleModal>
)
}

View file

@ -17,19 +17,22 @@
.security-state-not-managed {
color: @red;
}
.managed-user-row {
overflow-wrap: break-word;
}
.managed-user-actions {
button.dropdown-toggle {
color: @text-color;
padding-left: 1em;
padding-right: 1em;
}
li > a {
li > button {
&:hover {
background-color: @gray-lightest;
}
}
.delete-user-action {
a {
button {
color: @red;
}
}

View file

@ -21,6 +21,7 @@
"about_to_delete_tag": "You are about to delete the following tag (any projects in them will not be deleted):",
"about_to_delete_the_following_project": "You are about to delete the following project",
"about_to_delete_the_following_projects": "You are about to delete the following projects",
"about_to_delete_user_preamble": "Youre about to delete __userName__ (__userEmail__). Doing this will mean:",
"about_to_enable_managed_users": "Youre about to enable Managed Users for your organization. Once Managed Users is enabled, you cant disable it.",
"about_to_leave_projects": "You are about to leave the following projects:",
"about_to_trash_projects": "You are about to trash the following projects:",
@ -83,6 +84,7 @@
"all_premium_features_including": "All premium features, including:",
"all_prices_displayed_are_in_currency": "All prices displayed are in __recommendedCurrency__.",
"all_projects": "All Projects",
"all_projects_will_be_transferred_immediately": "All projects will be transferred to the new owner immediately.",
"all_templates": "All Templates",
"already_have_sl_account": "Already have an __appName__ account?",
"also": "Also",
@ -214,6 +216,7 @@
"checking_dropbox_status": "Checking Dropbox status",
"checking_project_github_status": "Checking project status in GitHub",
"choose_a_custom_color": "Choose a custom color",
"choose_from_group_members": "Choose from Group Members",
"choose_your_plan": "Choose your plan",
"city": "City",
"clear_cached_files": "Clear cached files",
@ -261,6 +264,7 @@
"confirm": "Confirm",
"confirm_affiliation": "Confirm Affiliation",
"confirm_affiliation_to_relink_dropbox": "Please confirm you are still at the institution and on their license, or upgrade your account in order to relink your Dropbox account.",
"confirm_delete_user_type_email_address": "To confirm you want to delete __userName__ please type the email address associated with their account",
"confirm_email": "Confirm Email",
"confirm_new_password": "Confirm New Password",
"confirm_primary_email_change": "Confirm primary email change",
@ -861,6 +865,7 @@
"learn_more": "Learn more",
"learn_more_about_emails": "<0>Learn more</0> about managing your __appName__ emails.",
"learn_more_about_link_sharing": "Learn more about Link Sharing",
"learn_more_about_managed_users": "Learn more about Managed Users.",
"learn_more_lowercase": "learn more",
"leave": "Leave",
"leave_any_group_subscriptions": "Leave any group subscriptions other than the one that will be managing your account. <0>Leave them from the Subscription page.</0>",
@ -1406,6 +1411,7 @@
"see_what_has_been": "See what has been ",
"select_a_file": "Select a File",
"select_a_file_figure_modal": "Select a file",
"select_a_new_owner_for_projects": "Select a new owner for this users projects",
"select_a_payment_method": "Select a payment method",
"select_a_project": "Select a Project",
"select_a_project_figure_modal": "Select a project",
@ -1586,12 +1592,15 @@
"the_supplied_parameters_were_invalid": "The link to open this content on Overleaf included some invalid parameters. If this keeps happening for links on a particular site, please report this to them.",
"the_supplied_uri_is_invalid": "The link to open this content on Overleaf included an invalid URI. If this keeps happening for links on a particular site, please report this to them.",
"the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "The width you choose here is based on the width of the text in your document. Alternatively, you can customize the image size directly in the LaTeX code.",
"their_projects_will_be_transferred_to_another_user": "Their projects will all be transferred to another user of your choice",
"theme": "Theme",
"then_x_price_per_month": "Then __price__ per month",
"then_x_price_per_year": "Then __price__ per year",
"there_are_lots_of_options_to_edit_and_customize_your_figures": "There are lots of options to edit and customize your figures, such as wrapping text around the figure, rotating the image, or including multiple images in a single figure. Youll need to edit the LaTeX code to do this. <0>Find out how</0>",
"there_was_an_error_opening_your_content": "There was an error creating your project",
"thesis": "Thesis",
"they_lose_access_to_account": "They lose all access to this Overleaf account immediately",
"this_action_cannot_be_reversed": "This action cannot be reversed.",
"this_action_cannot_be_undone": "This action cannot be undone.",
"this_address_will_be_shown_on_the_invoice": "This address will be shown on the invoice",
"this_field_is_required": "This field is required",
@ -1666,6 +1675,8 @@
"transfer_management_of_your_account": "Transfer management of your Overleaf account",
"transfer_management_of_your_account_to_x": "Transfer management of your Overleaf account to __groupName__",
"transfer_management_resolve_following_issues": "To transfer the management of your account, you need to resolve the following issues:",
"transfer_this_users_projects": "Transfer this users projects",
"transfer_this_users_projects_description": "This users projects will be transferred to a new owner.",
"transferring": "Transferring",
"trash": "Trash",
"trash_projects": "Trash Projects",
@ -1837,6 +1848,7 @@
"you_introed_small_number": " Youve introduced <0>__numberOfPeople__</0> person to __appName__. Good job, but can you get some more?",
"you_not_introed_anyone_to_sl": "Youve not introduced anyone to __appName__ yet. Get sharing!",
"you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "<0>You will be able to contact us</0> any time to share your feedback",
"you_will_be_able_to_reassign_subscription": "You will be able to reassign their subscription membership to another person in your organization",
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
"your_browser_does_not_support_this_feature": "Sorry, your browser doesnt support this feature. Please update your browser to its latest version.",
"your_git_access_info": "Your Git authentication tokens should be entered whenever youre prompted for a password.",

View file

@ -1,4 +1,5 @@
import ManagedUserDropdownButton from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button'
import sinon from 'sinon'
describe('ManagedUserDropdownButton', function () {
describe('with managed user', function () {
@ -17,7 +18,12 @@ describe('ManagedUserDropdownButton', function () {
}
beforeEach(function () {
cy.mount(<ManagedUserDropdownButton user={user} />)
cy.mount(
<ManagedUserDropdownButton
user={user}
openOffboardingModalForUser={sinon.stub()}
/>
)
})
it('should render the button', function () {
@ -47,7 +53,12 @@ describe('ManagedUserDropdownButton', function () {
}
beforeEach(function () {
cy.mount(<ManagedUserDropdownButton user={user} />)
cy.mount(
<ManagedUserDropdownButton
user={user}
openOffboardingModalForUser={sinon.stub()}
/>
)
})
it('should render the button', function () {

View file

@ -30,6 +30,7 @@ describe('ManagedUserRow', function () {
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
openOffboardingModalForUser={sinon.stub()}
/>
)
})
@ -79,6 +80,7 @@ describe('ManagedUserRow', function () {
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
openOffboardingModalForUser={sinon.stub()}
/>
)
})
@ -115,6 +117,7 @@ describe('ManagedUserRow', function () {
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
openOffboardingModalForUser={sinon.stub()}
/>
)
})
@ -152,6 +155,7 @@ describe('ManagedUserRow', function () {
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
openOffboardingModalForUser={sinon.stub()}
/>
)
})
@ -189,6 +193,7 @@ describe('ManagedUserRow', function () {
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
openOffboardingModalForUser={sinon.stub()}
/>
)
})
@ -231,6 +236,7 @@ describe('ManagedUserRow', function () {
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
openOffboardingModalForUser={sinon.stub()}
/>
)
})

View file

@ -2,6 +2,7 @@ import ManagedUsersList from '../../../../../../frontend/js/features/group-manag
import { User } from '../../../../../../types/group-management/user'
describe('ManagedUsersList', function () {
const groupId = 'somegroup'
describe('with users', function () {
const users: User[] = [
{
@ -38,6 +39,7 @@ describe('ManagedUsersList', function () {
handleSelectAllClick={handleSelectAllClick}
selectUser={selectUser}
unselectUser={unselectUser}
groupId={groupId}
/>
)
})
@ -83,6 +85,7 @@ describe('ManagedUsersList', function () {
handleSelectAllClick={handleSelectAllClick}
selectUser={selectUser}
unselectUser={unselectUser}
groupId={groupId}
/>
)
})

View file

@ -0,0 +1,107 @@
import OffboardManagedUserModal from '../../../../../../frontend/js/features/group-management/components/managed-users/offboard-managed-user-modal'
import sinon from 'sinon'
describe('OffboardManagedUserModal', function () {
describe('happy path', function () {
const groupId = 'some-group'
const user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: {
managedBy: `${groupId}`,
enrolledAt: new Date(),
},
isEntityAdmin: undefined,
}
const otherUser = {
_id: 'other-user',
email: 'other.user@example.com',
first_name: 'Other',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: `${groupId}`,
enrolledAt: new Date(),
},
isEntityAdmin: undefined,
}
const allMembers = [user, otherUser]
beforeEach(function () {
cy.mount(
<OffboardManagedUserModal
user={user}
allMembers={allMembers}
groupId={groupId}
onClose={sinon.stub()}
/>
)
})
it('should render the modal', function () {
cy.get('#delete-user-form').should('exist')
})
it('should disable the button if a recipient is not selected', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Not selecting a recipient...
// Fill in the email input
cy.get('#supplied-email-input').type(user.email)
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
})
it('should disable the button if the email is not filled in', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Select a recipient
cy.get('#recipient-select-input').select('other.user@example.com')
// Not filling in the email...
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
})
it('should disable the button if the email does not match the user', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Select a recipient
cy.get('#recipient-select-input').select('other.user@example.com')
// Fill in the email input, with the wrong email address
cy.get('#supplied-email-input').type('totally.wrong@example.com')
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
})
it('should fill out the form, and enable the delete button', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Select a recipient
cy.get('#recipient-select-input').select('other.user@example.com')
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
// Fill in the email input
cy.get('#supplied-email-input').type(user.email)
// Button should be enabled now
cy.get('button[type="submit"]').should('not.be.disabled')
})
})
})

View file

@ -195,6 +195,15 @@ describe('OwnershipTransferHandler', function () {
)
})
it('should not send an email notification with the skipEmails option', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id,
{ skipEmails: true }
)
expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
})
it('should track the change in BigQuery', async function () {
const sessionUserId = ObjectId()
await this.handler.promises.transferOwnership(

View file

@ -67,6 +67,29 @@ describe('TagsHandler', function () {
})
})
describe('when truncate=true, and tag is too long', function () {
it('should truncate the tag name', function (done) {
// Expect the tag to end up with this truncated name
this.tag.name = 'a comically long tag that will be truncated intern'
this.TagMock.expects('create')
.withArgs(this.tag)
.once()
.yields(null, this.tag)
this.TagsHandler.createTag(
this.tag.user_id,
// Pass this too-long name
'a comically long tag that will be truncated internally and not throw an error',
this.tag.color,
{ truncate: true },
(err, resultTag) => {
expect(err).to.not.exist
expect(resultTag.name).to.equal(this.tag.name)
done()
}
)
})
})
describe('when tag is too long', function () {
it('should throw an error', function (done) {
this.TagsHandler.createTag(