mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #13604 from overleaf/jk-managed-users-offboarding-ui
[web] Managed Users offboarding UI GitOrigin-RevId: ee4a1ae7cdb0022839ef232836ef6933443400fc
This commit is contained in:
parent
be7fd54257
commit
49eafa2712
17 changed files with 508 additions and 84 deletions
|
@ -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) {
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -203,6 +203,7 @@ export default function GroupMembers() {
|
|||
users={users}
|
||||
selectUser={selectUser}
|
||||
unselectUser={unselectUser}
|
||||
groupId={groupId}
|
||||
/>
|
||||
) : (
|
||||
<GroupMembersList
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": "You’re about to delete __userName__ (__userEmail__). Doing this will mean:",
|
||||
"about_to_enable_managed_users": "You’re about to enable Managed Users for your organization. Once Managed Users is enabled, you can’t 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 user’s 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. You’ll 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 user’s projects",
|
||||
"transfer_this_users_projects_description": "This user’s projects will be transferred to a new owner.",
|
||||
"transferring": "Transferring",
|
||||
"trash": "Trash",
|
||||
"trash_projects": "Trash Projects",
|
||||
|
@ -1837,6 +1848,7 @@
|
|||
"you_introed_small_number": " You’ve introduced <0>__numberOfPeople__</0> person to __appName__. Good job, but can you get some more?",
|
||||
"you_not_introed_anyone_to_sl": "You’ve 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 doesn’t support this feature. Please update your browser to its latest version.",
|
||||
"your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue