diff --git a/services/web/app/src/Features/UserMembership/UserMembershipController.js b/services/web/app/src/Features/UserMembership/UserMembershipController.js
index 547971d440..f0b700e7a7 100644
--- a/services/web/app/src/Features/UserMembership/UserMembershipController.js
+++ b/services/web/app/src/Features/UserMembership/UserMembershipController.js
@@ -20,7 +20,7 @@ const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const CSVParser = require('json2csv').Parser
const logger = require('@overleaf/logger')
-async function index(req, res, next) {
+async function manageGroupMembers(req, res, next) {
try {
const assignment = await SplitTestHandler.promises.getAssignment(
req,
@@ -28,7 +28,7 @@ async function index(req, res, next) {
'subscription-pages-react'
)
if (assignment.variant === 'active') {
- await _indexReact(req, res, next)
+ await _manageGroupMembersReact(req, res, next)
} else {
await _indexAngular(req, res, next)
}
@@ -41,7 +41,85 @@ async function index(req, res, next) {
}
}
-function _indexReact(req, res, next) {
+async function manageGroupManagers(req, res, next) {
+ try {
+ const assignment = await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'subscription-pages-react'
+ )
+ if (assignment.variant === 'active') {
+ await _renderManagersPage(
+ req,
+ res,
+ next,
+ 'user_membership/group-managers-react'
+ )
+ } else {
+ await _indexAngular(req, res, next)
+ }
+ } catch (error) {
+ logger.warn(
+ { err: error },
+ 'failed to get "subscription-pages-react" split test assignment'
+ )
+ await _indexAngular(req, res, next)
+ }
+}
+
+async function manageInstitutionManagers(req, res, next) {
+ try {
+ const assignment = await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'subscription-pages-react'
+ )
+ if (assignment.variant === 'active') {
+ await _renderManagersPage(
+ req,
+ res,
+ next,
+ 'user_membership/institution-managers-react'
+ )
+ } else {
+ await _indexAngular(req, res, next)
+ }
+ } catch (error) {
+ logger.warn(
+ { err: error },
+ 'failed to get "subscription-pages-react" split test assignment'
+ )
+ await _indexAngular(req, res, next)
+ }
+}
+
+async function managePublisherManagers(req, res, next) {
+ try {
+ const assignment = await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'subscription-pages-react'
+ )
+ if (assignment.variant === 'active') {
+ await _renderManagersPage(
+ req,
+ res,
+ next,
+ 'user_membership/publisher-managers-react'
+ )
+ } else {
+ await _indexAngular(req, res, next)
+ }
+ } catch (error) {
+ logger.warn(
+ { err: error },
+ 'failed to get "subscription-pages-react" split test assignment'
+ )
+ await _indexAngular(req, res, next)
+ }
+}
+
+async function _manageGroupMembersReact(req, res, next) {
const { entity, entityConfig } = req
return entity.fetchV1Data(function (error, entity) {
if (error != null) {
@@ -60,14 +138,40 @@ function _indexReact(req, res, next) {
if (entityConfig.fields.name) {
entityName = entity[entityConfig.fields.name]
}
- return res.render('user_membership/index-react', {
+ return res.render('user_membership/group-members-react', {
+ name: entityName,
+ groupId: entityPrimaryKey,
+ users,
+ groupSize: entity.membersLimit,
+ })
+ }
+ )
+ })
+}
+
+async function _renderManagersPage(req, res, next, template) {
+ const { entity, entityConfig } = req
+ return entity.fetchV1Data(function (error, entity) {
+ if (error != null) {
+ return next(error)
+ }
+ return UserMembershipHandler.getUsers(
+ entity,
+ entityConfig,
+ function (error, users) {
+ let entityName
+ if (error != null) {
+ return next(error)
+ }
+ const entityPrimaryKey =
+ entity[entityConfig.fields.primaryKey].toString()
+ if (entityConfig.fields.name) {
+ entityName = entity[entityConfig.fields.name]
+ }
+ return res.render(template, {
name: entityName,
users,
- groupSize: entityConfig.hasMembersLimit
- ? entity.membersLimit
- : undefined,
- translations: entityConfig.translations,
- paths: entityConfig.pathsFor(entityPrimaryKey),
+ groupId: entityPrimaryKey,
})
}
)
@@ -108,7 +212,10 @@ function _indexAngular(req, res, next) {
}
module.exports = {
- index,
+ manageGroupMembers,
+ manageGroupManagers,
+ manageInstitutionManagers,
+ managePublisherManagers,
add(req, res, next) {
const { entity, entityConfig } = req
const email = EmailHelper.parseEmail(req.body.email)
diff --git a/services/web/app/src/Features/UserMembership/UserMembershipRouter.js b/services/web/app/src/Features/UserMembership/UserMembershipRouter.js
index c88d53257b..39b54aed9d 100644
--- a/services/web/app/src/Features/UserMembership/UserMembershipRouter.js
+++ b/services/web/app/src/Features/UserMembership/UserMembershipRouter.js
@@ -22,7 +22,7 @@ module.exports = {
webRouter.get(
'/manage/groups/:id/members',
UserMembershipMiddleware.requireGroupManagementAccess,
- UserMembershipController.index
+ UserMembershipController.manageGroupMembers
)
webRouter.post(
'/manage/groups/:id/invites',
@@ -51,7 +51,7 @@ module.exports = {
webRouter.get(
'/manage/groups/:id/managers',
UserMembershipMiddleware.requireGroupManagersManagementAccess,
- UserMembershipController.index
+ UserMembershipController.manageGroupManagers
)
webRouter.post(
'/manage/groups/:id/managers',
@@ -68,7 +68,7 @@ module.exports = {
webRouter.get(
'/manage/institutions/:id/managers',
UserMembershipMiddleware.requireInstitutionManagementAccess,
- UserMembershipController.index
+ UserMembershipController.manageInstitutionManagers
)
webRouter.post(
'/manage/institutions/:id/managers',
@@ -85,7 +85,7 @@ module.exports = {
webRouter.get(
'/manage/publishers/:id/managers',
UserMembershipMiddleware.requirePublisherManagementAccess,
- UserMembershipController.index
+ UserMembershipController.managePublisherManagers
)
webRouter.post(
'/manage/publishers/:id/managers',
diff --git a/services/web/app/views/user_membership/group-managers-react.pug b/services/web/app/views/user_membership/group-managers-react.pug
new file mode 100644
index 0000000000..f4d8c0e973
--- /dev/null
+++ b/services/web/app/views/user_membership/group-managers-react.pug
@@ -0,0 +1,12 @@
+extends ../layout-marketing
+
+block entrypointVar
+ - entrypoint = 'pages/user/subscription/group-management/group-managers'
+
+block append meta
+ meta(name="ol-users", data-type="json", content=users)
+ meta(name="ol-groupId", data-type="string", content=groupId)
+ meta(name="ol-groupName", data-type="string", content=name)
+
+block content
+ main.content.content-alt#subscription-manage-group-root
diff --git a/services/web/app/views/user_membership/group-members-react.pug b/services/web/app/views/user_membership/group-members-react.pug
new file mode 100644
index 0000000000..264b8a7222
--- /dev/null
+++ b/services/web/app/views/user_membership/group-members-react.pug
@@ -0,0 +1,13 @@
+extends ../layout-marketing
+
+block entrypointVar
+ - entrypoint = 'pages/user/subscription/group-management/group-members'
+
+block append meta
+ meta(name="ol-users", data-type="json", content=users)
+ 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)
+
+block content
+ main.content.content-alt#subscription-manage-group-root
diff --git a/services/web/app/views/user_membership/index-react.pug b/services/web/app/views/user_membership/index-react.pug
deleted file mode 100644
index 1deb768f12..0000000000
--- a/services/web/app/views/user_membership/index-react.pug
+++ /dev/null
@@ -1,12 +0,0 @@
-extends ../layout-marketing
-
-block entrypointVar
- - entrypoint = 'pages/user/membership/groups'
-
-block append meta
- meta(name="ol-users", data-type="json", content=users)
- meta(name="ol-paths", data-type="json", content=paths)
- meta(name="ol-groupSize", data-type="json", content=groupSize)
-
-block content
- main.content.content-alt#subscription-manage-groups-root
diff --git a/services/web/app/views/user_membership/institution-managers-react.pug b/services/web/app/views/user_membership/institution-managers-react.pug
new file mode 100644
index 0000000000..690e8409f2
--- /dev/null
+++ b/services/web/app/views/user_membership/institution-managers-react.pug
@@ -0,0 +1,12 @@
+extends ../layout-marketing
+
+block entrypointVar
+ - entrypoint = 'pages/user/subscription/group-management/institution-managers'
+
+block append meta
+ meta(name="ol-users", data-type="json", content=users)
+ meta(name="ol-groupId", data-type="string", content=groupId)
+ meta(name="ol-groupName", data-type="string", content=name)
+
+block content
+ main.content.content-alt#subscription-manage-group-root
diff --git a/services/web/app/views/user_membership/publisher-managers-react.pug b/services/web/app/views/user_membership/publisher-managers-react.pug
new file mode 100644
index 0000000000..793bdf9602
--- /dev/null
+++ b/services/web/app/views/user_membership/publisher-managers-react.pug
@@ -0,0 +1,12 @@
+extends ../layout-marketing
+
+block entrypointVar
+ - entrypoint = 'pages/user/subscription/group-management/publisher-managers'
+
+block append meta
+ meta(name="ol-users", data-type="json", content=users)
+ meta(name="ol-groupId", data-type="string", content=groupId)
+ meta(name="ol-groupName", data-type="string", content=name)
+
+block content
+ main.content.content-alt#subscription-manage-group-root
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index cbf7a0191e..2a3759df7e 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -6,6 +6,7 @@
"about_to_delete_the_following": "",
"about_to_leave_projects": "",
"about_to_trash_projects": "",
+ "accepted_invite": "",
"access_denied": "",
"access_your_projects_with_git": "",
"account_has_been_link_to_institution_account": "",
@@ -14,14 +15,18 @@
"account_settings": "",
"acct_linked_to_institution_acct_2": "",
"actions": "",
+ "add": "",
"add_affiliation": "",
"add_another_email": "",
+ "add_comma_separated_emails_help": "",
"add_email_to_claim_features": "",
"add_files": "",
+ "add_more_members": "",
"add_new_email": "",
"add_or_remove_project_from_tag": "",
"add_role_and_department": "",
"add_to_folder": "",
+ "adding": "",
"additional_licenses": "",
"all_projects": "",
"also": "",
@@ -201,6 +206,7 @@
"error_performing_request": "",
"example_project": "",
"expand": "",
+ "export_csv": "",
"export_project_to_github": "",
"fast": "",
"faster_compiles_feedback_question": "",
@@ -298,6 +304,7 @@
"go_to_pdf_location_in_code": "",
"group_plan_tooltip": "",
"group_plan_with_name_tooltip": "",
+ "group_subscription": "",
"have_an_extra_backup": "",
"headers": "",
"help": "",
@@ -359,6 +366,8 @@
"labs_program_already_participating": "",
"labs_program_benefits": "<0>0>",
"labs_program_not_participating": "",
+ "last_active": "",
+ "last_active_description": "",
"last_modified": "",
"last_name": "",
"last_resort_trouble_shooting_guide": "",
@@ -415,9 +424,11 @@
"manage_members": "",
"manage_newsletter": "",
"manage_sessions": "",
+ "managers_management": "",
"math_display": "",
"math_inline": "",
"maximum_files_uploaded_together": "",
+ "members_management": "",
"mendeley_groups_loading_error": "",
"mendeley_groups_relink": "",
"mendeley_integration": "",
@@ -430,6 +441,7 @@
"more": "",
"n_items": "",
"n_items_plural": "",
+ "name": "",
"navigate_log_source": "",
"navigation": "",
"need_to_add_new_primary_before_remove": "",
@@ -445,6 +457,7 @@
"newsletter": "",
"next_payment_of_x_collectected_on_y": "",
"no_existing_password": "",
+ "no_members": "",
"no_messages": "",
"no_new_commits_in_github": "",
"no_other_projects_found": "",
@@ -573,7 +586,10 @@
"remote_service_error": "",
"remove": "",
"remove_collaborator": "",
+ "remove_from_group": "",
+ "remove_manager": "",
"remove_tag": "",
+ "removing": "",
"rename": "",
"rename_folder": "",
"rename_project": "",
@@ -611,6 +627,7 @@
"search_whole_word": "",
"select_a_file": "",
"select_a_project": "",
+ "select_all": "",
"select_all_projects": "",
"select_an_output_file": "",
"select_from_output_files": "",
@@ -620,6 +637,7 @@
"select_project": "",
"select_projects": "",
"select_tag": "",
+ "select_user": "",
"selected": "",
"send": "",
"send_first_message": "",
@@ -789,6 +807,7 @@
"you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
"you_can_now_log_in_sso": "",
"you_dont_have_any_repositories": "",
+ "you_have_added_x_of_group_size_y": "",
"your_affiliation_is_confirmed": "",
"your_browser_does_not_support_this_feature": "",
"your_message_to_collaborators": "",
diff --git a/services/web/frontend/js/features/group-management/components/error-alert.tsx b/services/web/frontend/js/features/group-management/components/error-alert.tsx
new file mode 100644
index 0000000000..a7f971dba5
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/error-alert.tsx
@@ -0,0 +1,31 @@
+import { useTranslation } from 'react-i18next'
+
+export type APIError = {
+ message?: string
+}
+
+type ErrorAlertProps = {
+ error?: APIError
+}
+
+export default function ErrorAlert({ error }: ErrorAlertProps) {
+ const { t } = useTranslation()
+
+ if (!error) {
+ return null
+ }
+
+ if (error.message) {
+ return (
+
+ {t('error')}: {error.message}
+
+ )
+ }
+
+ return (
+
+ {t('generic_something_went_wrong')}
+
+ )
+}
diff --git a/services/web/frontend/js/features/group-management/components/group-managers.tsx b/services/web/frontend/js/features/group-management/components/group-managers.tsx
new file mode 100644
index 0000000000..af13423c47
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/group-managers.tsx
@@ -0,0 +1,37 @@
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
+import getMeta from '../../../utils/meta'
+import { ManagersTable } from './managers-table'
+
+export default function GroupManagers() {
+ const { isReady } = useWaitForI18n()
+ const { t } = useTranslation()
+
+ const groupId: string = getMeta('ol-groupId')
+ const groupName: string = getMeta('ol-groupName')
+
+ const paths = useMemo(
+ () => ({
+ addMember: `/manage/groups/${groupId}/managers`,
+ removeMember: `/manage/groups/${groupId}/managers`,
+ }),
+ [groupId]
+ )
+
+ if (!isReady) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/group-management/components/group-member-row.tsx b/services/web/frontend/js/features/group-management/components/group-member-row.tsx
new file mode 100644
index 0000000000..04127b1b39
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/group-member-row.tsx
@@ -0,0 +1,81 @@
+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'
+
+type GroupMemberRowProps = {
+ user: User
+ selectUser: (user: User) => void
+ unselectUser: (user: User) => void
+ selected: boolean
+}
+
+export default function GroupMemberRow({
+ user,
+ selectUser,
+ unselectUser,
+ selected,
+}: GroupMemberRowProps) {
+ const { t } = useTranslation()
+
+ const handleSelectUser = useCallback(
+ (event, user) => {
+ if (event.target.checked) {
+ selectUser(user)
+ } else {
+ unselectUser(user)
+ }
+ },
+ [selectUser, unselectUser]
+ )
+
+ return (
+
+
+
+
+ handleSelectUser(e, user)}
+ />
+ {user.email}
+
+
+ {user.first_name} {user.last_name}
+
+
+ {user.last_active_at
+ ? moment(user.last_active_at).format('Do MMM YYYY')
+ : 'N/A'}
+
+
+ {user.invite ? (
+ <>
+
+ t('invite_not_accepted')
+ >
+ ) : (
+ <>
+
+ t('accepted_invite')
+ >
+ )}
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/group-management/components/group-members.tsx b/services/web/frontend/js/features/group-management/components/group-members.tsx
new file mode 100644
index 0000000000..0767a4e427
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/group-members.tsx
@@ -0,0 +1,293 @@
+import { useCallback, useMemo, useState } from 'react'
+import { Button, Col, Form, FormControl, Row } from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+import { User } from '../../../../../types/group-management/user'
+import {
+ deleteJSON,
+ FetchError,
+ postJSON,
+} from '../../../infrastructure/fetch-json'
+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'
+
+export default function GroupMembers() {
+ const { isReady } = useWaitForI18n()
+ const { t } = useTranslation()
+
+ const {
+ users,
+ setUsers,
+ selectedUsers,
+ selectAllUsers,
+ unselectAllUsers,
+ selectUser,
+ unselectUser,
+ } = useUserSelection(getMeta('ol-users', []))
+
+ const [emailString, setEmailString] = useState('')
+ const [inviteUserInflightCount, setInviteUserInflightCount] = useState(0)
+ const [inviteError, setInviteError] = useState()
+ const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
+ const [removeMemberError, setRemoveMemberError] = useState()
+
+ const groupId: string = getMeta('ol-groupId')
+ const groupName: string = getMeta('ol-groupName')
+ const groupSize: number = getMeta('ol-groupSize')
+
+ const paths = useMemo(
+ () => ({
+ addMember: `/manage/groups/${groupId}/invites`,
+ removeMember: `/manage/groups/${groupId}/user`,
+ removeInvite: `/manage/groups/${groupId}/invites`,
+ exportMembers: `/manage/groups/${groupId}/members/export`,
+ }),
+ [groupId]
+ )
+
+ const addMembers = useCallback(
+ e => {
+ e.preventDefault()
+ setInviteError(undefined)
+ const emails = parseEmails(emailString)
+ ;(async () => {
+ for (const email of emails) {
+ setInviteUserInflightCount(count => count + 1)
+ try {
+ const data = await postJSON<{ user: User }>(paths.addMember, {
+ body: {
+ email,
+ },
+ })
+ if (data.user) {
+ const alreadyListed = users.find(
+ user => user.email === data.user.email
+ )
+ if (!alreadyListed) {
+ setUsers(users => [...users, data.user])
+ }
+ }
+ setEmailString('')
+ } catch (error: unknown) {
+ console.error(error)
+ setInviteError((error as FetchError)?.data?.error || {})
+ }
+ setInviteUserInflightCount(count => count - 1)
+ }
+ })()
+ },
+ [emailString, paths.addMember, users, setUsers]
+ )
+
+ const removeMembers = useCallback(
+ e => {
+ e.preventDefault()
+ setRemoveMemberError(undefined)
+ ;(async () => {
+ for (const user of selectedUsers) {
+ let url
+ if (paths.removeInvite && user.invite && user._id == null) {
+ url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
+ } else if (paths.removeMember && user._id) {
+ url = `${paths.removeMember}/${user._id}`
+ } else {
+ return
+ }
+ setRemoveMemberInflightCount(count => count + 1)
+ try {
+ await deleteJSON(url, {})
+ setUsers(users => users.filter(u => u !== user))
+ unselectUser(user)
+ } catch (error: unknown) {
+ console.error(error)
+ setRemoveMemberError((error as FetchError)?.data?.error || {})
+ }
+ setRemoveMemberInflightCount(count => count - 1)
+ }
+ })()
+ },
+ [
+ selectedUsers,
+ unselectUser,
+ setUsers,
+ paths.removeInvite,
+ paths.removeMember,
+ ]
+ )
+
+ const handleSelectAllClick = useCallback(
+ (e: React.ChangeEvent) => {
+ if (e.target.checked) {
+ selectAllUsers()
+ } else {
+ unselectAllUsers()
+ }
+ },
+ [selectAllUsers, unselectAllUsers]
+ )
+
+ const handleEmailsChange = useCallback(
+ e => {
+ setEmailString(e.target.value)
+ },
+ [setEmailString]
+ )
+
+ if (!isReady) {
+ return null
+ }
+
+ return (
+
+
+
+ {groupName || t('group_subscription')}
+
+
+
+ {selectedUsers.length === 0 && (
+
+ , ]} // eslint-disable-line react/jsx-key
+ values={{ addedUsersSize: users.length, groupSize }}
+ />
+
+ )}
+ {removeMemberInflightCount > 0 ? (
+
+ ) : (
+ <>
+ {selectedUsers.length > 0 && (
+
+ )}
+ >
+ )}
+
+
{t('members_management')}
+
+
+
+ {users.length < groupSize && (
+
+
{t('add_more_members')}
+
+
+
+ )}
+ {users.length >= groupSize && users.length > 0 && (
+ <>
+
+
+
+ {t('export_csv')}
+
+
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/group-management/components/institution-managers.tsx b/services/web/frontend/js/features/group-management/components/institution-managers.tsx
new file mode 100644
index 0000000000..3d279e3f4c
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/institution-managers.tsx
@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next'
+import { useMemo } from 'react'
+import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
+import getMeta from '../../../utils/meta'
+import { ManagersTable } from './managers-table'
+
+export default function InstitutionManagers() {
+ const { isReady } = useWaitForI18n()
+ const { t } = useTranslation()
+
+ const groupId: string = getMeta('ol-groupId')
+ const groupName: string = getMeta('ol-groupName')
+
+ const paths = useMemo(
+ () => ({
+ addMember: `/manage/institutions/${groupId}/managers`,
+ removeMember: `/manage/institutions/${groupId}/managers`,
+ }),
+ [groupId]
+ )
+
+ if (!isReady) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/group-management/components/managers-table.tsx b/services/web/frontend/js/features/group-management/components/managers-table.tsx
new file mode 100644
index 0000000000..2684f3b7c7
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/managers-table.tsx
@@ -0,0 +1,260 @@
+import { useCallback, useState } from 'react'
+import { Button, Col, Form, FormControl, Row } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import {
+ deleteJSON,
+ FetchError,
+ postJSON,
+} from '../../../infrastructure/fetch-json'
+import Tooltip from '../../../shared/components/tooltip'
+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 { User } from '../../../../../types/group-management/user'
+
+type ManagersPaths = {
+ addMember: string
+ removeMember: string
+}
+
+type UsersTableProps = {
+ groupName: string
+ paths: ManagersPaths
+ translations: {
+ title: string
+ subtitle: string
+ remove: string
+ }
+}
+
+export function ManagersTable({
+ groupName,
+ translations,
+ paths,
+}: UsersTableProps) {
+ const { t } = useTranslation()
+
+ const {
+ users,
+ setUsers,
+ selectedUsers,
+ selectAllUsers,
+ unselectAllUsers,
+ selectUser,
+ unselectUser,
+ } = useUserSelection(getMeta('ol-users', []))
+
+ const [emailString, setEmailString] = useState('')
+ const [inviteUserInflightCount, setInviteUserInflightCount] = useState(0)
+ const [inviteError, setInviteError] = useState()
+ const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
+ const [removeMemberError, setRemoveMemberError] = useState()
+
+ const addManagers = useCallback(
+ e => {
+ e.preventDefault()
+ setInviteError(undefined)
+ const emails = parseEmails(emailString)
+ ;(async () => {
+ for (const email of emails) {
+ setInviteUserInflightCount(count => count + 1)
+ try {
+ const data = await postJSON<{ user: User }>(paths.addMember, {
+ body: {
+ email,
+ },
+ })
+ if (data.user) {
+ const alreadyListed = users.find(
+ user => user.email === data.user.email
+ )
+ if (!alreadyListed) {
+ setUsers(users => [...users, data.user])
+ }
+ }
+ setEmailString('')
+ } catch (error: unknown) {
+ console.error(error)
+ setInviteError((error as FetchError)?.data?.error || {})
+ }
+ setInviteUserInflightCount(count => count - 1)
+ }
+ })()
+ },
+ [emailString, paths.addMember, users, setUsers]
+ )
+
+ const removeManagers = useCallback(
+ e => {
+ e.preventDefault()
+ setRemoveMemberError(undefined)
+ ;(async () => {
+ for (const user of selectedUsers) {
+ let url
+ if (paths.removeMember && user._id) {
+ url = `${paths.removeMember}/${user._id}`
+ } else {
+ return
+ }
+ setRemoveMemberInflightCount(count => count + 1)
+ try {
+ await deleteJSON(url, {})
+ setUsers(users => users.filter(u => u !== user))
+ unselectUser(user)
+ } catch (error: unknown) {
+ console.error(error)
+ setRemoveMemberError((error as FetchError)?.data?.error || {})
+ }
+ setRemoveMemberInflightCount(count => count - 1)
+ }
+ })()
+ },
+ [selectedUsers, unselectUser, setUsers, paths.removeMember]
+ )
+
+ const handleSelectAllClick = useCallback(
+ (e: React.ChangeEvent) => {
+ if (e.target.checked) {
+ selectAllUsers()
+ } else {
+ unselectAllUsers()
+ }
+ },
+ [selectAllUsers, unselectAllUsers]
+ )
+
+ const handleEmailsChange = useCallback(
+ e => {
+ setEmailString(e.target.value)
+ },
+ [setEmailString]
+ )
+
+ return (
+
+
+
+ {groupName || translations.title}
+
+
+
+ {removeMemberInflightCount > 0 ? (
+
+ ) : (
+ <>
+ {selectedUsers.length > 0 && (
+
+ )}
+ >
+ )}
+
+
{translations.subtitle}
+
+
+
+
+
{t('add_more_members')}
+
+
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/group-management/components/publisher-managers.tsx b/services/web/frontend/js/features/group-management/components/publisher-managers.tsx
new file mode 100644
index 0000000000..962f335f2c
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/publisher-managers.tsx
@@ -0,0 +1,37 @@
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
+import getMeta from '../../../utils/meta'
+import { ManagersTable } from './managers-table'
+
+export default function PublisherManagers() {
+ const { isReady } = useWaitForI18n()
+ const { t } = useTranslation()
+
+ const groupId: string = getMeta('ol-groupId')
+ const groupName: string = getMeta('ol-groupName')
+
+ const paths = useMemo(
+ () => ({
+ addMember: `/manage/publishers/${groupId}/managers`,
+ removeMember: `/manage/publishers/${groupId}/managers`,
+ }),
+ [groupId]
+ )
+
+ if (!isReady) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/group-management/hooks/use-user-selection.ts b/services/web/frontend/js/features/group-management/hooks/use-user-selection.ts
new file mode 100644
index 0000000000..19c4069b95
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/hooks/use-user-selection.ts
@@ -0,0 +1,29 @@
+import { useCallback, useState } from 'react'
+import { User } from '../../../../../types/group-management/user'
+
+export default function useUserSelection(initialUsers: User[]) {
+ const [users, setUsers] = useState(initialUsers)
+ const [selectedUsers, setSelectedUsers] = useState([])
+
+ const selectAllUsers = () => setSelectedUsers(users)
+
+ const unselectAllUsers = () => setSelectedUsers([])
+
+ const selectUser = useCallback((user: User) => {
+ setSelectedUsers(users => [...users, user])
+ }, [])
+
+ const unselectUser = useCallback((user: User) => {
+ setSelectedUsers(users => users.filter(u => u.email !== user.email))
+ }, [])
+
+ return {
+ users,
+ setUsers,
+ selectedUsers,
+ selectUser,
+ unselectUser,
+ selectAllUsers,
+ unselectAllUsers,
+ }
+}
diff --git a/services/web/frontend/js/features/group-management/utils/emails.ts b/services/web/frontend/js/features/group-management/utils/emails.ts
new file mode 100644
index 0000000000..a18040e2a9
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/utils/emails.ts
@@ -0,0 +1,9 @@
+import _ from 'lodash'
+
+export function parseEmails(emailsString: string) {
+ const regexBySpaceOrComma = /[\s,]+/
+ let emails = emailsString.split(regexBySpaceOrComma)
+ emails = _.map(emails, email => email.trim())
+ emails = _.filter(emails, email => email.indexOf('@') !== -1)
+ return emails
+}
diff --git a/services/web/frontend/js/features/membership/components/groups-root.tsx b/services/web/frontend/js/features/membership/components/groups-root.tsx
deleted file mode 100644
index 378b1630df..0000000000
--- a/services/web/frontend/js/features/membership/components/groups-root.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-function Root() {
- return React Manage Group Subscription
-}
-
-export default Root
diff --git a/services/web/frontend/js/pages/user/membership/groups.js b/services/web/frontend/js/pages/user/subscription/group-management/group-managers.js
similarity index 58%
rename from services/web/frontend/js/pages/user/membership/groups.js
rename to services/web/frontend/js/pages/user/subscription/group-management/group-managers.js
index a13e6f0c9a..70533ccc44 100644
--- a/services/web/frontend/js/pages/user/membership/groups.js
+++ b/services/web/frontend/js/pages/user/subscription/group-management/group-managers.js
@@ -1,7 +1,8 @@
+import '../base'
import ReactDOM from 'react-dom'
-import Root from '../../../features/membership/components/groups-root'
+import Root from '../../../../features/group-management/components/group-managers'
-const element = document.getElementById('subscription-manage-groups-root')
+const element = document.getElementById('subscription-manage-group-root')
if (element) {
ReactDOM.render(, element)
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/group-members.js b/services/web/frontend/js/pages/user/subscription/group-management/group-members.js
new file mode 100644
index 0000000000..7710364dd7
--- /dev/null
+++ b/services/web/frontend/js/pages/user/subscription/group-management/group-members.js
@@ -0,0 +1,8 @@
+import '../base'
+import ReactDOM from 'react-dom'
+import Members from '../../../../features/group-management/components/group-members'
+
+const element = document.getElementById('subscription-manage-group-root')
+if (element) {
+ ReactDOM.render(, element)
+}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/institution-managers.js b/services/web/frontend/js/pages/user/subscription/group-management/institution-managers.js
new file mode 100644
index 0000000000..2ea1d2d7cf
--- /dev/null
+++ b/services/web/frontend/js/pages/user/subscription/group-management/institution-managers.js
@@ -0,0 +1,8 @@
+import '../base'
+import ReactDOM from 'react-dom'
+import Root from '../../../../features/group-management/components/institution-managers'
+
+const element = document.getElementById('subscription-manage-group-root')
+if (element) {
+ ReactDOM.render(, element)
+}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.js b/services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.js
new file mode 100644
index 0000000000..f323afa459
--- /dev/null
+++ b/services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.js
@@ -0,0 +1,8 @@
+import '../base'
+import ReactDOM from 'react-dom'
+import Root from '../../../../features/group-management/components/publisher-managers'
+
+const element = document.getElementById('subscription-manage-group-root')
+if (element) {
+ ReactDOM.render(, element)
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index a8557dd5f2..546e662622 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1282,6 +1282,7 @@
"select_a_payment_method": "Select a payment method",
"select_a_project": "Select a Project",
"select_a_zip_file": "Select a .zip file",
+ "select_all": "Select all",
"select_all_projects": "Select all projects",
"select_an_output_file": "Select an Output File",
"select_country_vat": "Please select your country on the payment page to view the total price including any VAT.",
@@ -1291,6 +1292,7 @@
"select_project": "Select __project__",
"select_projects": "Select Projects",
"select_tag": "Select tag __tagName__",
+ "select_user": "Select user",
"selected": "Selected",
"send": "Send",
"send_first_message": "Send your first message to your collaborators",
diff --git a/services/web/test/frontend/features/group-management/components/group-managers.spec.tsx b/services/web/test/frontend/features/group-management/components/group-managers.spec.tsx
new file mode 100644
index 0000000000..2708973891
--- /dev/null
+++ b/services/web/test/frontend/features/group-management/components/group-managers.spec.tsx
@@ -0,0 +1,154 @@
+import GroupManagers from '../../../../../frontend/js/features/group-management/components/group-managers'
+
+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 GROUP_ID = '888fff888fff'
+const PATHS = {
+ addMember: `/manage/groups/${GROUP_ID}/managers`,
+ removeMember: `/manage/groups/${GROUP_ID}/managers`,
+}
+
+describe('group managers', function () {
+ beforeEach(function () {
+ cy.window().then(win => {
+ win.metaAttributesCache = new Map()
+ win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
+ win.metaAttributesCache.set('ol-groupId', GROUP_ID)
+ win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
+ })
+
+ cy.mount()
+ })
+
+ it('renders the group management page', function () {
+ cy.get('h1').contains('My Awesome Team')
+
+ cy.get('ul').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="Invite not yet accepted"]`)
+ })
+
+ cy.get('li:nth-child(3)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(4)').within(() => {
+ cy.contains('someone.else@test.com')
+ cy.contains('N/A')
+ cy.get(`[aria-label="Invite not yet accepted"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+ cy.get('.alert').contains('Error: User already added')
+ })
+
+ it('checks the select all checkbox', function () {
+ cy.get('ul').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').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').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.get('.select-item').check()
+ })
+ })
+
+ cy.get('button').contains('Remove manager').click()
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ it('tries to remove a manager and displays the error', function () {
+ cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+ statusCode: 500,
+ })
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.get('.select-item').check()
+ })
+ })
+ cy.get('button').contains('Remove manager').click()
+
+ cy.get('.alert').contains('Sorry, something went wrong')
+ })
+})
diff --git a/services/web/test/frontend/features/group-management/components/group-members.spec.tsx b/services/web/test/frontend/features/group-management/components/group-members.spec.tsx
new file mode 100644
index 0000000000..663bfce272
--- /dev/null
+++ b/services/web/test/frontend/features/group-management/components/group-members.spec.tsx
@@ -0,0 +1,159 @@
+import GroupMembers from '../../../../../frontend/js/features/group-management/components/group-members'
+
+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 GROUP_ID = '777fff777fff'
+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', function () {
+ beforeEach(function () {
+ cy.window().then(win => {
+ win.metaAttributesCache = new Map()
+ win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
+ win.metaAttributesCache.set('ol-groupId', GROUP_ID)
+ win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
+ win.metaAttributesCache.set('ol-groupSize', 10)
+ })
+
+ cy.mount()
+ })
+
+ it('renders the group members page', function () {
+ cy.get('h1').contains('My Awesome Team')
+ cy.get('small').contains('You have added 2 of 10 available members')
+
+ cy.get('ul').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="Invite not yet accepted"]`)
+ })
+
+ cy.get('li:nth-child(3)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(4)').within(() => {
+ cy.contains('someone.else@test.com')
+ cy.contains('N/A')
+ cy.get(`[aria-label="Invite not yet accepted"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+ cy.get('.alert').contains('Error: User already added')
+ })
+
+ it('checks the select all checkbox', function () {
+ cy.get('ul').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').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').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 1 of 10 available members')
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ it('tries to remove a user and displays the error', function () {
+ cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+ statusCode: 500,
+ })
+
+ cy.get('ul').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')
+ })
+})
diff --git a/services/web/test/frontend/features/group-management/components/institution-managers.spec.tsx b/services/web/test/frontend/features/group-management/components/institution-managers.spec.tsx
new file mode 100644
index 0000000000..7dc3337d52
--- /dev/null
+++ b/services/web/test/frontend/features/group-management/components/institution-managers.spec.tsx
@@ -0,0 +1,154 @@
+import InstitutionManagers from '../../../../../frontend/js/features/group-management/components/institution-managers'
+
+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 GROUP_ID = '999fff999fff'
+const PATHS = {
+ addMember: `/manage/institutions/${GROUP_ID}/managers`,
+ removeMember: `/manage/institutions/${GROUP_ID}/managers`,
+}
+
+describe('institution managers', function () {
+ beforeEach(function () {
+ cy.window().then(win => {
+ win.metaAttributesCache = new Map()
+ win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
+ win.metaAttributesCache.set('ol-groupId', GROUP_ID)
+ win.metaAttributesCache.set('ol-groupName', 'My Awesome Institution')
+ })
+
+ cy.mount()
+ })
+
+ it('renders the institution management page', function () {
+ cy.get('h1').contains('My Awesome Institution')
+
+ cy.get('ul').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="Invite not yet accepted"]`)
+ })
+
+ cy.get('li:nth-child(3)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(4)').within(() => {
+ cy.contains('someone.else@test.com')
+ cy.contains('N/A')
+ cy.get(`[aria-label="Invite not yet accepted"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+ cy.get('.alert').contains('Error: User already added')
+ })
+
+ it('checks the select all checkbox', function () {
+ cy.get('ul').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').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').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.get('.select-item').check()
+ })
+ })
+
+ cy.get('button').contains('Remove manager').click()
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ it('tries to remove a manager and displays the error', function () {
+ cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+ statusCode: 500,
+ })
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.get('.select-item').check()
+ })
+ })
+ cy.get('button').contains('Remove manager').click()
+
+ cy.get('.alert').contains('Sorry, something went wrong')
+ })
+})
diff --git a/services/web/test/frontend/features/group-management/components/publisher-managers.spec.tsx b/services/web/test/frontend/features/group-management/components/publisher-managers.spec.tsx
new file mode 100644
index 0000000000..aa027d93c6
--- /dev/null
+++ b/services/web/test/frontend/features/group-management/components/publisher-managers.spec.tsx
@@ -0,0 +1,154 @@
+import PublisherManagers from '../../../../../frontend/js/features/group-management/components/publisher-managers'
+
+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 GROUP_ID = '000fff000fff'
+const PATHS = {
+ addMember: `/manage/publishers/${GROUP_ID}/managers`,
+ removeMember: `/manage/publishers/${GROUP_ID}/managers`,
+}
+
+describe('publisher managers', function () {
+ beforeEach(function () {
+ cy.window().then(win => {
+ win.metaAttributesCache = new Map()
+ win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
+ win.metaAttributesCache.set('ol-groupId', GROUP_ID)
+ win.metaAttributesCache.set('ol-groupName', 'My Awesome Publisher')
+ })
+
+ cy.mount()
+ })
+
+ it('renders the publisher management page', function () {
+ cy.get('h1').contains('My Awesome Publisher')
+
+ cy.get('ul').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="Invite not yet accepted"]`)
+ })
+
+ cy.get('li:nth-child(3)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(4)').within(() => {
+ cy.contains('someone.else@test.com')
+ cy.contains('N/A')
+ cy.get(`[aria-label="Invite not yet accepted"]`)
+ })
+ })
+ })
+
+ 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('button').click()
+ cy.get('.alert').contains('Error: User already added')
+ })
+
+ it('checks the select all checkbox', function () {
+ cy.get('ul').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').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').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.get('.select-item').check()
+ })
+ })
+
+ cy.get('button').contains('Remove manager').click()
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.contains('bobby.lapointe@test.com')
+ cy.contains('Bobby Lapointe')
+ cy.contains('2nd Jan 2023')
+ cy.get(`[aria-label="Accepted invite"]`)
+ })
+ })
+ })
+
+ it('tries to remove a manager and displays the error', function () {
+ cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
+ statusCode: 500,
+ })
+
+ cy.get('ul').within(() => {
+ cy.get('li:nth-child(2)').within(() => {
+ cy.get('.select-item').check()
+ })
+ })
+ cy.get('button').contains('Remove manager').click()
+
+ cy.get('.alert').contains('Sorry, something went wrong')
+ })
+})
diff --git a/services/web/test/unit/src/UserMembership/UserMembershipControllerTests.js b/services/web/test/unit/src/UserMembership/UserMembershipControllerTests.js
index cd47c6c223..d288339f05 100644
--- a/services/web/test/unit/src/UserMembership/UserMembershipControllerTests.js
+++ b/services/web/test/unit/src/UserMembership/UserMembershipControllerTests.js
@@ -96,7 +96,7 @@ describe('UserMembershipController', function () {
})
it('get users', async function () {
- return await this.UserMembershipController.index(this.req, {
+ return await this.UserMembershipController.manageGroupMembers(this.req, {
render: () => {
sinon.assert.calledWithMatch(
this.UserMembershipHandler.getUsers,
@@ -108,7 +108,7 @@ describe('UserMembershipController', function () {
})
it('render group view', async function () {
- return await this.UserMembershipController.index(this.req, {
+ return await this.UserMembershipController.manageGroupMembers(this.req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/index')
expect(viewParams.users).to.deep.equal(this.users)
@@ -123,7 +123,7 @@ describe('UserMembershipController', function () {
it('render group managers view', async function () {
this.req.entityConfig = EntityConfigs.groupManagers
- return await this.UserMembershipController.index(this.req, {
+ return await this.UserMembershipController.manageGroupManagers(this.req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/index')
expect(viewParams.groupSize).to.equal(undefined)
@@ -139,15 +139,20 @@ describe('UserMembershipController', function () {
it('render institution view', async function () {
this.req.entity = this.institution
this.req.entityConfig = EntityConfigs.institution
- return await this.UserMembershipController.index(this.req, {
- render: (viewPath, viewParams) => {
- expect(viewPath).to.equal('user_membership/index')
- expect(viewParams.name).to.equal('Test Institution Name')
- expect(viewParams.groupSize).to.equal(undefined)
- expect(viewParams.translations.title).to.equal('institution_account')
- expect(viewParams.paths.exportMembers).to.be.undefined
- },
- })
+ return await this.UserMembershipController.manageInstitutionManagers(
+ this.req,
+ {
+ render: (viewPath, viewParams) => {
+ expect(viewPath).to.equal('user_membership/index')
+ expect(viewParams.name).to.equal('Test Institution Name')
+ expect(viewParams.groupSize).to.equal(undefined)
+ expect(viewParams.translations.title).to.equal(
+ 'institution_account'
+ )
+ expect(viewParams.paths.exportMembers).to.be.undefined
+ },
+ }
+ )
})
})
diff --git a/services/web/types/group-management/user.ts b/services/web/types/group-management/user.ts
new file mode 100644
index 0000000000..c6b21bfbc9
--- /dev/null
+++ b/services/web/types/group-management/user.ts
@@ -0,0 +1,8 @@
+export type User = {
+ _id: string
+ email: string
+ first_name: string
+ last_name: string
+ invite: boolean
+ last_active_at: Date
+}