Merge pull request #13483 from overleaf/jk-managed-users-group-management-ui

[web] Managed Users: Update Group Member Management UI

GitOrigin-RevId: 6896951927f0e3220db59dda208d7cfe9c6c309e
This commit is contained in:
June Kelly 2023-06-30 09:30:20 +01:00 committed by Copybot
parent c3a2786d82
commit a14e2aecfb
24 changed files with 1237 additions and 63 deletions

View file

@ -17,9 +17,15 @@ const EmailHelper = require('../Helpers/EmailHelper')
const { csvAttachment } = require('../../infrastructure/Response')
const { UserIsManagerError } = require('./UserMembershipErrors')
const CSVParser = require('json2csv').Parser
const Settings = require('@overleaf/settings')
function isManagedUsersActiveOnGroup(entity) {
return !!(Settings.managedUsers?.enabled && entity.groupPolicy)
}
async function manageGroupMembers(req, res, next) {
const { entity, entityConfig } = req
const managedUsersActive = isManagedUsersActiveOnGroup(entity)
return entity.fetchV1Data(function (error, entity) {
if (error != null) {
return next(error)
@ -42,6 +48,7 @@ async function manageGroupMembers(req, res, next) {
groupId: entityPrimaryKey,
users,
groupSize: entity.membersLimit,
managedUsersActive,
})
}
)

View file

@ -106,7 +106,21 @@ function getPopulatedListOfMembers(entity, attributes, callback) {
}
}
async.map(userObjects, UserMembershipViewModel.buildAsync, callback)
async.map(userObjects, UserMembershipViewModel.buildAsync, (err, users) => {
if (err) {
return callback(err)
}
for (const user of users) {
if (
user?._id &&
entity?.admin_id &&
user._id.toString() === entity.admin_id.toString()
) {
user.isEntityAdmin = true
}
}
callback(null, users)
})
}
function addUserToEntity(entity, attribute, user, callback) {

View file

@ -39,6 +39,7 @@ module.exports = UserMembershipViewModel = {
last_name: 1,
lastLoggedIn: 1,
lastActive: 1,
enrollment: 1,
}
return UserGetter.getUser(userId, projection, function (error, user) {
if (error != null || user == null) {
@ -61,6 +62,12 @@ function buildUserViewModel(user, isInvite) {
last_active_at: user.lastActive || user.lastLoggedIn || null,
last_logged_in_at: user.lastLoggedIn || null,
invite: isInvite,
enrollment: user.enrollment
? {
managedBy: user.enrollment.managedBy,
enrolledAt: user.enrollment.enrolledAt,
}
: undefined,
}
}

View file

@ -8,6 +8,7 @@ block append meta
meta(name="ol-groupId", data-type="string", content=groupId)
meta(name="ol-groupName", data-type="string", content=name)
meta(name="ol-groupSize", data-type="json", content=groupSize)
meta(name="ol-managedUsersActive", data-type="boolean", content=managedUsersActive)
block content
main.content.content-alt#subscription-manage-group-root

View file

@ -826,6 +826,10 @@ module.exports = {
// ID of the IEEE brand in the rails app
ieeeBrandId: intFromEnv('IEEE_BRAND_ID', 15),
managedUsers: {
enabled: false,
},
}
module.exports.mergeWith = function (overrides) {

View file

@ -198,6 +198,7 @@
"delete_projects": "",
"delete_tag": "",
"delete_token": "",
"delete_user": "",
"delete_your_account": "",
"deleted_at": "",
"deleted_by_on": "",
@ -395,6 +396,7 @@
"go_to_code_location_in_pdf": "",
"go_to_pdf_location_in_code": "",
"go_to_settings": "",
"group_admin": "",
"group_plan_tooltip": "",
"group_plan_with_name_tooltip": "",
"group_subscription": "",
@ -577,6 +579,7 @@
"manage_publisher_managers": "",
"manage_sessions": "",
"manage_subscription": "",
"managed": "",
"managed_users": "",
"managed_users_explanation": "",
"managers_management": "",
@ -624,6 +627,7 @@
"new_to_latex_look_at": "",
"newsletter": "",
"next_payment_of_x_collectected_on_y": "",
"no_actions": "",
"no_comments": "",
"no_existing_password": "",
"no_folder": "",
@ -646,6 +650,7 @@
"normal": "",
"normally_x_price_per_month": "",
"normally_x_price_per_year": "",
"not_managed": "",
"notification_project_invite_accepted_message": "",
"notification_project_invite_message": "",
"number_of_users": "",
@ -689,6 +694,7 @@
"pdf_viewer": "",
"pdf_viewer_error": "",
"pending_additional_licenses": "",
"pending_invite": "",
"percent_discount_for_groups": "",
"plan": "",
"plan_tooltip": "",
@ -848,6 +854,7 @@
"search_search_for": "",
"search_whole_word": "",
"search_within_selection": "",
"security": "",
"select_a_file": "",
"select_a_file_figure_modal": "",
"select_a_payment_method": "",

View file

@ -0,0 +1,82 @@
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 GroupMemberRow from './group-member-row'
type GroupMembersListProps = {
handleSelectAllClick: (e: any) => void
selectedUsers: User[]
users: User[]
selectUser: (user: User) => void
unselectUser: (user: User) => void
}
export default function GroupMembersList({
handleSelectAllClick,
selectedUsers,
users,
selectUser,
unselectUser,
}: GroupMembersListProps) {
const { t } = useTranslation()
return (
<ul className="list-unstyled structured-list">
<li className="container-fluid">
<Row>
<Col xs={4}>
<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={4}>
<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('accepted_invite')}</span>
</Col>
</Row>
</li>
{users.length === 0 && (
<li>
<Row>
<Col md={12} className="text-centered">
<small>{t('no_members')}</small>
</Col>
</Row>
</li>
)}
{users.map((user: any) => (
<GroupMemberRow
key={user.email}
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selectedUsers.includes(user)}
/>
))}
</ul>
)
}

View file

@ -8,13 +8,13 @@ import {
postJSON,
} from '../../../infrastructure/fetch-json'
import MaterialIcon from '../../../shared/components/material-icon'
import Tooltip from '../../../shared/components/tooltip'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import getMeta from '../../../utils/meta'
import { parseEmails } from '../utils/emails'
import ErrorAlert, { APIError } from './error-alert'
import GroupMemberRow from './group-member-row'
import useUserSelection from '../hooks/use-user-selection'
import ManagedUsersList from './managed-users/managed-users-list'
import GroupMembersList from './group-members-list'
export default function GroupMembers() {
const { isReady } = useWaitForI18n()
@ -39,6 +39,7 @@ export default function GroupMembers() {
const groupId: string = getMeta('ol-groupId')
const groupName: string = getMeta('ol-groupName')
const groupSize: number = getMeta('ol-groupSize')
const managedUsersActive: any = getMeta('ol-managedUsersActive')
const paths = useMemo(
() => ({
@ -90,6 +91,9 @@ export default function GroupMembers() {
setRemoveMemberError(undefined)
;(async () => {
for (const user of selectedUsers) {
if (user?.enrollment?.managedBy) {
continue
}
let url
if (paths.removeInvite && user.invite && user._id == null) {
url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
@ -142,6 +146,13 @@ export default function GroupMembers() {
return null
}
const shouldShowRemoveUsersButton = () => {
return (
selectedUsers.length > 0 &&
!selectedUsers.find((u: User) => !!u?.enrollment?.managedBy)
)
}
return (
<div className="container">
<Row>
@ -173,7 +184,7 @@ export default function GroupMembers() {
</Button>
) : (
<>
{selectedUsers.length > 0 && (
{shouldShowRemoveUsersButton() && (
<Button bsStyle="danger" onClick={removeMembers}>
{t('remove_from_group')}
</Button>
@ -185,67 +196,27 @@ export default function GroupMembers() {
</div>
<div className="row-spaced-small">
<ErrorAlert error={removeMemberError} />
<ul className="list-unstyled structured-list">
<li className="container-fluid">
<Row>
<Col xs={4}>
<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={4}>
<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('accepted_invite')}</span>
</Col>
</Row>
</li>
{users.length === 0 && (
<li>
<Row>
<Col md={12} className="text-centered">
<small>{t('no_members')}</small>
</Col>
</Row>
</li>
)}
{users.map((user: any) => (
<GroupMemberRow
key={user.email}
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selectedUsers.includes(user)}
/>
))}
</ul>
{managedUsersActive ? (
<ManagedUsersList
handleSelectAllClick={handleSelectAllClick}
selectedUsers={selectedUsers}
users={users}
selectUser={selectUser}
unselectUser={unselectUser}
/>
) : (
<GroupMembersList
handleSelectAllClick={handleSelectAllClick}
selectedUsers={selectedUsers}
users={users}
selectUser={selectUser}
unselectUser={unselectUser}
/>
)}
</div>
<hr />
{users.length < groupSize && (
<div>
<div className="add-more-members-form">
<p className="small">{t('add_more_members')}</p>
<ErrorAlert error={inviteError} />
<Form horizontal onSubmit={addMembers} className="form">

View file

@ -0,0 +1,40 @@
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'
type ManagedUserDropdownButtonProps = {
user: User
}
export default function ManagedUserDropdownButton({
user,
}: ManagedUserDropdownButtonProps) {
const { t } = useTranslation()
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>
) : (
<>
<MenuItem className="no-actions-available">
<span className="text-muted">{t('no_actions')}</span>
</MenuItem>
</>
)}
</Dropdown.Menu>
</ControlledDropdown>
)
}

View file

@ -0,0 +1,107 @@
import moment from 'moment'
import { 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 ManagedUserDropdownButton from './managed-user-dropdown-button'
import Tooltip from '../../../../shared/components/tooltip'
import ManagedUserStatus from './managed-user-status'
type ManagedUserRowProps = {
user: User
selectUser: (user: User) => void
unselectUser: (user: User) => void
selected: boolean
}
export default function ManagedUserRow({
user,
selectUser,
unselectUser,
selected,
}: ManagedUserRowProps) {
const { t } = useTranslation()
const handleSelectUser = useCallback(
(event, user) => {
if (event.target.checked) {
selectUser(user)
} else {
unselectUser(user)
}
},
[selectUser, unselectUser]
)
return (
<li
key={`user-${user.email}`}
className={`managed-user-row ${user.invite ? 'text-muted' : ''}`}
>
<Row>
<Col xs={6}>
<label htmlFor={`select-user-${user.email}`} className="sr-only">
{t('select_user')}
</label>
<input
className="select-item"
id={`select-user-${user.email}`}
type="checkbox"
checked={selected}
onChange={e => handleSelectUser(e, user)}
/>
<span>
{user.email}
{user.invite ? (
<span>
&nbsp;
<Tooltip
id={`pending-invite-symbol-${user._id}`}
description={t('pending_invite')}
>
<Badge aria-label={t('pending_invite')}>
{t('pending_invite')}
</Badge>
</Tooltip>
</span>
) : (
''
)}
{user.isEntityAdmin && (
<span>
&nbsp;
<Tooltip
id={`group-admin-symbol-${user._id}`}
description={t('group_admin')}
>
<i
className="fa fa-user-circle-o"
aria-hidden="true"
aria-label={t('group_admin')}
/>
</Tooltip>
</span>
)}
</span>
</Col>
<Col xs={2}>
{user.first_name} {user.last_name}
</Col>
<Col xs={2}>
{user.last_active_at
? moment(user.last_active_at).format('Do MMM YYYY')
: 'N/A'}
</Col>
<Col xs={2}>
<span className="pull-right">
<ManagedUserStatus user={user} />
<span className="managed-user-actions">
<ManagedUserDropdownButton user={user} />
</span>
</span>
</Col>
</Row>
</li>
)
}

View file

@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
type ManagedUserStatusProps = {
user: User
}
export default function ManagedUserStatus({ user }: ManagedUserStatusProps) {
const { t } = useTranslation()
return (
<span>
{user.isEntityAdmin ? (
<>
<span className="security-state-group-admin" />
</>
) : (
<>
{user.invite ? (
<span className="security-state-invite-pending">
<i
className="fa fa-clock-o"
aria-hidden="true"
aria-label={t('pending_invite')}
/>
&nbsp;
{t('managed')}
</span>
) : (
<>
{user.enrollment?.managedBy ? (
<span className="security-state-managed">
<i
className="fa fa-check"
aria-hidden="true"
aria-label={t('managed')}
/>
&nbsp;
{t('managed')}
</span>
) : (
<span className="security-state-not-managed">
<i
className="fa fa-times"
aria-hidden="true"
aria-label={t('not_managed')}
/>
&nbsp;
{t('managed')}
</span>
)}
</>
)}
</>
)}
</span>
)
}

View file

@ -0,0 +1,82 @@
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 ManagedUserRow from './managed-user-row'
type ManagedUsersListProps = {
handleSelectAllClick: (e: any) => void
selectedUsers: User[]
users: User[]
selectUser: (user: User) => void
unselectUser: (user: User) => void
}
export default function ManagedUsersList({
handleSelectAllClick,
selectedUsers,
users,
selectUser,
unselectUser,
}: ManagedUsersListProps) {
const { t } = useTranslation()
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>
</Col>
</Row>
</li>
)}
{users.map((user: any) => (
<ManagedUserRow
key={user.email}
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selectedUsers.includes(user)}
/>
))}
</ul>
)
}

View file

@ -63,6 +63,7 @@
@import 'components/beta-badges.less';
@import 'components/divider.less';
@import 'components/split-menu.less';
@import 'components/group-members.less';
// Components w/ JavaScript
@import 'components/modals.less';

View file

@ -0,0 +1,37 @@
/* Styles for group-subscription members view */
.structured-list.managed-users-list {
/* Override scrolling behaviour on structured-list */
overflow: initial;
overflow-y: initial;
overflow-x: initial;
}
.managed-users-list {
.security-state-invite-pending {
color: @text-muted;
}
.security-state-managed {
color: @green;
}
.security-state-not-managed {
color: @red;
}
.managed-user-actions {
button.dropdown-toggle {
color: @text-color;
padding-left: 1em;
padding-right: 1em;
}
li > a {
&:hover {
background-color: @gray-lightest;
}
}
.delete-user-action {
a {
color: @red;
}
}
}
}

View file

@ -340,6 +340,7 @@
"delete_projects": "Delete Projects",
"delete_tag": "Delete Tag",
"delete_token": "Delete token",
"delete_user": "Delete User",
"delete_your_account": "Delete your account",
"deleted_at": "Deleted At",
"deleted_by_on": "Deleted by __name__ on __date__",
@ -656,6 +657,7 @@
"go_to_code_location_in_pdf": "Go to code location in PDF",
"go_to_pdf_location_in_code": "Go to PDF location in code (Tip: double click on the PDF for best results)",
"go_to_settings": "Go to settings",
"group_admin": "Group admin",
"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",
@ -950,6 +952,7 @@
"manage_publisher_managers": "Manage publisher managers",
"manage_sessions": "Manage Your Sessions",
"manage_subscription": "Manage Subscription",
"managed": "Managed",
"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>",
"managers_cannot_remove_admin": "Admins cannot be removed",
@ -1026,6 +1029,7 @@
"next_payment_of_x_collectected_on_y": "The next payment of <0>__paymentAmmount__</0> will be collected on <1>__collectionDate__</1>.",
"nl": "Dutch",
"no": "Norwegian",
"no_actions": "No actions",
"no_comments": "No comments",
"no_complicated_latex_install": "No complicated LaTeX installation",
"no_existing_password": "Please use the password reset form to set your password",
@ -1058,6 +1062,7 @@
"normally_x_price_per_year": "Normally __price__ per year",
"not_a_student_question": "Not a student?",
"not_found_error_from_the_supplied_url": "The link to open this content on Overleaf pointed to a file that could not be found. If this keeps happening for links on a particular site, please report this to them.",
"not_managed": "Not managed",
"not_now": "Not now",
"not_registered": "Not registered",
"note_experiments_under_development": "<0>Please note</0> that experiments in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a paid plan</0>",
@ -1139,6 +1144,7 @@
"pdf_viewer_error": "There was a problem displaying the PDF for this project.",
"pending": "Pending",
"pending_additional_licenses": "Your subscription is changing to include <0>__pendingAdditionalLicenses__</0> additional license(s) for a total of <1>__pendingTotalLicenses__</1> licenses.",
"pending_invite": "Pending invite",
"per_month": "per month",
"per_year": "per year",
"percent_discount_for_groups": "__appName__ offers a __percent__% educational discount for groups of __size__ or more.",

View file

@ -24,7 +24,7 @@ const PATHS = {
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
}
describe('group members', function () {
describe('group members, without managed users', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache = new Map()

View file

@ -0,0 +1,224 @@
import GroupMembers from '../../../../../../frontend/js/features/group-management/components/group-members'
const GROUP_ID = '777fff777fff'
const JOHN_DOE = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
const CLAIRE_JENNINGS = {
_id: 'defabc231453',
first_name: 'Claire',
last_name: 'Jennings',
email: 'claire.jennings@test.com',
last_active_at: new Date('2023-01-03'),
invite: false,
enrollment: {
managedBy: GROUP_ID,
enrolledAt: new Date('2023-01-03'),
},
}
const PATHS = {
addMember: `/manage/groups/${GROUP_ID}/invites`,
removeMember: `/manage/groups/${GROUP_ID}/user`,
removeInvite: `/manage/groups/${GROUP_ID}/invites`,
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
}
describe('group members, with managed users', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache = new Map()
win.metaAttributesCache.set('ol-users', [
JOHN_DOE,
BOBBY_LAPOINTE,
CLAIRE_JENNINGS,
])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
// Managed Users is active on this group
win.metaAttributesCache.set('ol-managedUsersActive', true)
})
cy.mount(<GroupMembers />)
})
it('renders the group members page', function () {
cy.get('h1').contains('My Awesome Team')
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.contains('john.doe@test.com')
cy.contains('John Doe')
cy.contains('15th Jan 2023')
cy.get(`[aria-label="Pending invite"]`)
cy.get('.badge-new-comment').contains('Pending invite')
cy.get(`.security-state-invite-pending`).should('exist')
})
cy.get('li:nth-child(3)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
cy.get('.badge-new-comment').should('not.exist')
cy.get('i[aria-label="Not managed"]').should('exist')
})
cy.get('li:nth-child(4)').within(() => {
cy.contains('claire.jennings@test.com')
cy.contains('Claire Jennings')
cy.contains('3rd Jan 2023')
cy.get('.badge-new-comment').should('not.exist')
cy.get('i[aria-label="Managed"]').should('exist')
})
})
})
it('sends an invite', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 201,
body: {
user: {
email: 'someone.else@test.com',
invite: true,
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(5)').within(() => {
cy.contains('someone.else@test.com')
cy.contains('N/A')
cy.get(`[aria-label="Pending invite"]`)
cy.get('.badge-new-comment').contains('Pending invite')
cy.get(`.security-state-invite-pending`).should('exist')
})
})
})
it('tries to send an invite and displays the error', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 500,
body: {
error: {
message: 'User already added',
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.get('.alert').contains('Error: User already added')
})
it('checks the select all checkbox', function () {
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('.select-item').should('not.be.checked')
})
cy.get('li:nth-child(3)').within(() => {
cy.get('.select-item').should('not.be.checked')
})
})
cy.get('.select-all').click()
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('.select-item').should('be.checked')
})
cy.get('li:nth-child(3)').within(() => {
cy.get('.select-item').should('be.checked')
})
})
})
it('remove a member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('.select-item').check()
})
})
cy.get('button').contains('Remove from group').click()
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.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
})
})
})
it('cannot remove a managed member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.get('ul.managed-users-list').within(() => {
// Select 'Claire Jennings', a managed user
cy.get('li:nth-child(4)').within(() => {
cy.get('.select-item').check()
})
})
cy.get('button').contains('Remove from group').should('not.exist')
})
it('does not show the remove-member button if any of the selected users are managed', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.get('ul.managed-users-list').within(() => {
// Select 'Claire Jennings', a managed user
cy.get('li:nth-child(4)').within(() => {
cy.get('.select-item').check()
})
// Select another user
cy.get('li:nth-child(3)').within(() => {
cy.get('.select-item').check()
})
})
cy.get('button').contains('Remove from group').should('not.exist')
})
it('tries to remove a user and displays the error', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 500,
})
cy.get('ul.managed-users-list').within(() => {
cy.get('li:nth-child(2)').within(() => {
cy.get('.select-item').check()
})
})
cy.get('button').contains('Remove from group').click()
cy.get('.alert').contains('Sorry, something went wrong')
})
})

View file

@ -0,0 +1,63 @@
import ManagedUserDropdownButton from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button'
describe('ManagedUserDropdownButton', function () {
describe('with managed user', function () {
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: 'some-group',
enrolledAt: new Date(),
},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserDropdownButton user={user} />)
})
it('should render the button', function () {
cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
cy.get(`.action-btn`).should('exist')
})
it('should show the menu when the button is clicked', function () {
cy.get('.action-btn').click()
cy.get('.delete-user-action').should('exist')
cy.get('.delete-user-action').then($el => {
Cypress.dom.isVisible($el)
})
})
})
describe('with non-managed user', function () {
const user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: undefined,
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserDropdownButton user={user} />)
})
it('should render the button', function () {
cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
cy.get(`.action-btn`).should('exist')
})
it('should show the (empty) menu when the button is clicked', function () {
cy.get('.action-btn').click()
cy.get('.no-actions-available').should('exist')
})
})
})

View file

@ -0,0 +1,246 @@
import sinon, { SinonStub } from 'sinon'
import ManagedUserRow from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-row'
import { User } from '../../../../../../types/group-management/user'
describe('ManagedUserRow', function () {
describe('with an ordinary user', function () {
let user: User,
selectUser: SinonStub,
unselectUser: SinonStub,
selected: boolean
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
selectUser = sinon.stub()
unselectUser = sinon.stub()
selected = false
cy.mount(
<ManagedUserRow
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
/>
)
})
it('renders the row', function () {
cy.get('.row').should('exist')
// Checkbox
cy.get('.select-item').should('not.be.checked')
// Email
cy.get('.row').contains(user.email)
// Name
cy.get('.row').contains(user.first_name)
cy.get('.row').contains(user.last_name)
// Last active date
cy.get('.row').contains('21st Nov 2070')
// Managed status
cy.get('.row').contains('Managed')
// Dropdown button
cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
})
})
describe('with a pending invite', function () {
let user: User,
selectUser: SinonStub,
unselectUser: SinonStub,
selected: boolean
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
selectUser = sinon.stub()
unselectUser = sinon.stub()
selected = false
cy.mount(
<ManagedUserRow
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
/>
)
})
it('should render a "Pending invite" badge', function () {
cy.get('.badge-new-comment').contains('Pending invite')
})
})
describe('with a group admin', function () {
let user: User,
selectUser: SinonStub,
unselectUser: SinonStub,
selected: boolean
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: true,
}
selectUser = sinon.stub()
unselectUser = sinon.stub()
selected = false
cy.mount(
<ManagedUserRow
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
/>
)
})
it('should render a "Group admin" symbol', function () {
cy.get('[aria-label="Group admin"].fa-user-circle-o').should('exist')
})
})
describe('user is selected', function () {
let user: User,
selectUser: SinonStub,
unselectUser: SinonStub,
selected: boolean
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
selectUser = sinon.stub()
unselectUser = sinon.stub()
// User is selected
selected = true
cy.mount(
<ManagedUserRow
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
/>
)
})
it('should render the selection box as selected', function () {
cy.get('.select-item').should('be.checked')
})
})
describe('selecting user row', function () {
let user: User,
selectUser: SinonStub,
unselectUser: SinonStub,
selected: boolean
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
selectUser = sinon.stub()
unselectUser = sinon.stub()
// User is not selected
selected = false
cy.mount(
<ManagedUserRow
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
/>
)
})
it('should select the user', function () {
cy.get('.select-item').should('not.be.checked')
cy.get('.select-item').click()
cy.get('.select-item').then(() => {
expect(selectUser.called).to.equal(true)
expect(unselectUser.called).to.equal(false)
})
})
})
describe('un-selecting user row', function () {
let user: User,
selectUser: SinonStub,
unselectUser: SinonStub,
selected: boolean
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
selectUser = sinon.stub()
unselectUser = sinon.stub()
// User is selected
selected = true
cy.mount(
<ManagedUserRow
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selected}
/>
)
})
it('should select the user', function () {
cy.get('.select-item').should('be.checked')
cy.get('.select-item').click()
cy.get('.select-item').then(() => {
expect(unselectUser.called).to.equal(true)
})
})
})
})

View file

@ -0,0 +1,86 @@
import ManagedUserStatus from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-status'
import { User } from '../../../../../../types/group-management/user'
describe('ManagedUserStatus', function () {
describe('with a pending invite', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: undefined,
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render a pending state', function () {
cy.get('.security-state-invite-pending').contains('Managed')
})
})
describe('with a managed user', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: { managedBy: 'some-group', enrolledAt: new Date() },
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render a pending state', function () {
cy.get('.security-state-managed').contains('Managed')
})
})
describe('with an un-managed user', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: undefined,
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render an un-managed state', function () {
cy.get('.security-state-not-managed').contains('Managed')
})
})
describe('with the group admin', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: undefined,
isEntityAdmin: true,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render no state indicator', function () {
cy.get('.security-state-group-admin')
.contains('Managed')
.should('not.exist')
})
})
})

View file

@ -0,0 +1,97 @@
import ManagedUsersList from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-users-list'
import { User } from '../../../../../../types/group-management/user'
describe('ManagedUsersList', function () {
describe('with users', function () {
const users: User[] = [
{
_id: 'user-one',
email: 'sarah.brennan@example.com',
first_name: 'Sarah',
last_name: 'Brennan',
invite: false,
last_active_at: new Date('2070-10-22T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
},
{
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
},
]
const selectedUsers: User[] = []
const handleSelectAllClick = () => {}
const selectUser = () => {}
const unselectUser = () => {}
beforeEach(function () {
cy.mount(
<ManagedUsersList
users={users}
selectedUsers={selectedUsers}
handleSelectAllClick={handleSelectAllClick}
selectUser={selectUser}
unselectUser={unselectUser}
/>
)
})
it('should render the table headers', function () {
cy.get('#managed-users-list-headers').should('exist')
// Select-all checkbox
cy.get('#managed-users-list-headers .select-all').should('exist')
cy.get('#managed-users-list-headers').contains('Email')
cy.get('#managed-users-list-headers').contains('Name')
cy.get('#managed-users-list-headers').contains('Last Active')
cy.get('#managed-users-list-headers').contains('Security')
})
it('should render the list of users', function () {
cy.get('.managed-users-list')
.find('.managed-user-row')
.should('have.length', 2)
// First user
cy.get('.managed-users-list').contains(users[0].email)
cy.get('.managed-users-list').contains(users[0].first_name)
cy.get('.managed-users-list').contains(users[0].last_name)
// Second user
cy.get('.managed-users-list').contains(users[1].email)
cy.get('.managed-users-list').contains(users[1].first_name)
cy.get('.managed-users-list').contains(users[1].last_name)
})
})
describe('empty user list', function () {
const users: User[] = []
const selectedUsers: User[] = []
const handleSelectAllClick = () => {}
const selectUser = () => {}
const unselectUser = () => {}
beforeEach(function () {
cy.mount(
<ManagedUsersList
users={users}
selectedUsers={selectedUsers}
handleSelectAllClick={handleSelectAllClick}
selectUser={selectUser}
unselectUser={unselectUser}
/>
)
})
it('should render the list, with a "no members" message', function () {
cy.get('.managed-users-list').contains('No members')
cy.get('.managed-users-list')
.find('.managed-user-row')
.should('have.length', 0)
})
})
})

View file

@ -59,6 +59,12 @@ describe('UserMembershipController', function () {
},
]
this.Settings = {
managedUsers: {
enabled: false,
},
}
this.SessionManager = {
getSessionUser: sinon.stub().returns(this.user),
getLoggedInUserId: sinon.stub().returns(this.user._id),
@ -84,6 +90,7 @@ describe('UserMembershipController', function () {
'../Authentication/SessionManager': this.SessionManager,
'../SplitTests/SplitTestHandler': this.SplitTestHandler,
'./UserMembershipHandler': this.UserMembershipHandler,
'@overleaf/settings': this.Settings,
},
}
))
@ -113,6 +120,20 @@ describe('UserMembershipController', function () {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(this.users)
expect(viewParams.groupSize).to.equal(this.subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(false)
},
})
})
it('render group view with managed users', async function () {
this.req.entity.groupPolicy = { somePolicy: true }
this.Settings.managedUsers.enabled = true
return await this.UserMembershipController.manageGroupMembers(this.req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(this.users)
expect(viewParams.groupSize).to.equal(this.subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(true)
},
})
})

View file

@ -39,6 +39,10 @@ describe('UserMembershipViewModel', function () {
email: 'mock-email@baz.com',
first_name: 'Name',
lastLoggedIn: '2020-05-20T10:41:11.407Z',
enrollment: {
managedBy: 'mock-group-id',
enrolledAt: new Date(),
},
}
})
@ -53,6 +57,7 @@ describe('UserMembershipViewModel', function () {
first_name: null,
last_name: null,
_id: null,
enrollment: undefined,
})
})
@ -66,6 +71,7 @@ describe('UserMembershipViewModel', function () {
first_name: this.user.first_name,
last_name: null,
_id: this.user._id,
enrollment: this.user.enrollment,
})
})
})
@ -107,6 +113,8 @@ describe('UserMembershipViewModel', function () {
expect(viewModel.first_name).to.equal(this.user.first_name)
expect(viewModel.invite).to.equal(false)
expect(viewModel.email).to.exist
expect(viewModel.enrollment).to.exist
expect(viewModel.enrollment).to.deep.equal(this.user.enrollment)
return done()
}
)

View file

@ -1,3 +1,8 @@
export type UserEnrollment = {
managedBy: string
enrolledAt: Date
}
export type User = {
_id: string
email: string
@ -5,4 +10,6 @@ export type User = {
last_name: string
invite: boolean
last_active_at: Date
enrollment: UserEnrollment | undefined
isEntityAdmin: boolean | undefined
}