mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
Merge pull request #30007 from overleaf/as-user-management-ro
[web] Initial read-only user management page GitOrigin-RevId: f50d2377b855e6541b30f8f946aecb59bf08e3bc
This commit is contained in:
@@ -175,6 +175,11 @@ function formatGroupPlansDataForDash() {
|
||||
async function userSubscriptionPage(req, res) {
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription')
|
||||
await SplitTestHandler.promises.getAssignment(
|
||||
req,
|
||||
res,
|
||||
'combined-user-management'
|
||||
)
|
||||
|
||||
const groupPricingDiscount = await SplitTestHandler.promises.getAssignment(
|
||||
req,
|
||||
|
||||
@@ -12,6 +12,8 @@ import PlansLocator from '../Subscription/PlansLocator.mjs'
|
||||
import RecurlyClient from '../Subscription/RecurlyClient.mjs'
|
||||
import Modules from '../../infrastructure/Modules.mjs'
|
||||
import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
|
||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||
import _ from 'lodash'
|
||||
|
||||
async function manageGroupMembers(req, res, next) {
|
||||
const { entity: subscription, entityConfig } = req
|
||||
@@ -136,6 +138,57 @@ async function _renderManagersPage(req, res, next, template) {
|
||||
})
|
||||
}
|
||||
|
||||
async function manageGroupUsers(req, res) {
|
||||
const combinedUserManagement = await SplitTestHandler.promises.getAssignment(
|
||||
req,
|
||||
res,
|
||||
'combined-user-management'
|
||||
)
|
||||
const { entity: subscription, entityConfig } = req
|
||||
|
||||
const entityPrimaryKey =
|
||||
subscription[entityConfig.fields.primaryKey].toString()
|
||||
|
||||
if (combinedUserManagement.variant !== 'enabled') {
|
||||
return res.redirect(`/manage/groups/${entityPrimaryKey}/members`)
|
||||
}
|
||||
|
||||
let entityName
|
||||
if (entityConfig.fields.name) {
|
||||
entityName = subscription[entityConfig.fields.name]
|
||||
}
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)?.toString()
|
||||
const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
|
||||
const isAdmin = subscription.admin_id.toString() === userId
|
||||
const recurlySubscription = subscription.recurlySubscription_id
|
||||
? await RecurlyClient.promises.getSubscription(
|
||||
subscription.recurlySubscription_id
|
||||
)
|
||||
: undefined
|
||||
const canUseAddSeatsFeature =
|
||||
plan?.canUseFlexibleLicensing &&
|
||||
isAdmin &&
|
||||
recurlySubscription &&
|
||||
!recurlySubscription.pendingChange
|
||||
const ssoConfig = await SSOConfig.findById(subscription.ssoConfig).exec()
|
||||
|
||||
const users = await UserMembershipHandler.promises.getUsers(
|
||||
subscription,
|
||||
entityConfig
|
||||
)
|
||||
|
||||
res.render('user_membership/group-users-react', {
|
||||
name: entityName,
|
||||
groupId: entityPrimaryKey,
|
||||
users: _.uniqBy(users, 'email'),
|
||||
groupSize: subscription.membersLimit,
|
||||
managedUsersActive: subscription.managedUsersEnabled,
|
||||
entityAccess: UserMembershipAuthorization.hasEntityAccess()(req),
|
||||
canUseAddSeatsFeature,
|
||||
groupSSOActive: ssoConfig?.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
async function exportCsv(req, res) {
|
||||
let ssoEnabled
|
||||
const { entity, entityConfig } = req
|
||||
@@ -284,6 +337,7 @@ async function create(req, res) {
|
||||
export default {
|
||||
manageGroupMembers: expressify(manageGroupMembers),
|
||||
manageGroupManagers: expressify(manageGroupManagers),
|
||||
manageGroupUsers: expressify(manageGroupUsers),
|
||||
manageInstitutionManagers: expressify(manageInstitutionManagers),
|
||||
managePublisherManagers: expressify(managePublisherManagers),
|
||||
add: expressify(add),
|
||||
|
||||
@@ -43,6 +43,22 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
groupUsers: {
|
||||
modelName: 'Subscription',
|
||||
hasMembersLimit: true,
|
||||
fields: {
|
||||
primaryKey: '_id',
|
||||
read: ['invited_emails', 'teamInvites', 'member_ids', 'manager_ids'],
|
||||
write: 'manager_ids',
|
||||
access: 'manager_ids',
|
||||
membership: 'member_ids',
|
||||
name: 'teamName',
|
||||
},
|
||||
baseQuery: {
|
||||
groupPlan: true,
|
||||
},
|
||||
},
|
||||
|
||||
groupMember: {
|
||||
modelName: 'Subscription',
|
||||
readOnly: true,
|
||||
|
||||
@@ -125,6 +125,20 @@ async function getPopulatedListOfMembers(entity, attributes) {
|
||||
) {
|
||||
user.isEntityAdmin = true
|
||||
}
|
||||
if (
|
||||
user?._id &&
|
||||
entity?.manager_ids &&
|
||||
entity.manager_ids.includes(user._id)
|
||||
) {
|
||||
user.isEntityManager = true
|
||||
}
|
||||
if (
|
||||
user?._id &&
|
||||
entity?.member_ids &&
|
||||
entity.member_ids.includes(user._id)
|
||||
) {
|
||||
user.isEntityMember = true
|
||||
}
|
||||
}
|
||||
|
||||
return users
|
||||
|
||||
@@ -253,6 +253,7 @@ const ObjectIdEntitySchema = z.object({
|
||||
'groupAdmin',
|
||||
'groupManagers',
|
||||
'groupMember',
|
||||
'groupUsers',
|
||||
]),
|
||||
params: z.object({
|
||||
id: zz.coercedObjectId(),
|
||||
|
||||
@@ -52,6 +52,11 @@ export default {
|
||||
RateLimiterMiddleware.rateLimit(rateLimiters.exportTeamCsv),
|
||||
UserMembershipController.exportCsv
|
||||
)
|
||||
webRouter.get(
|
||||
'/manage/groups/:id/users',
|
||||
UserMembershipMiddleware.requireEntityAccessOrAdminAccess('groupUsers'),
|
||||
UserMembershipController.manageGroupUsers
|
||||
)
|
||||
|
||||
// group managers routes
|
||||
webRouter.get(
|
||||
|
||||
27
services/web/app/views/user_membership/group-users-react.pug
Normal file
27
services/web/app/views/user_membership/group-users-react.pug
Normal file
@@ -0,0 +1,27 @@
|
||||
extends ../layout-react
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'pages/user/subscription/group-management/group-users'
|
||||
|
||||
block append meta
|
||||
- var hasWriteAccess = entityAccess || (hasAdminAccess() && hasAdminCapability(managedUsersActive ? 'modify-managed-group-member' : 'modify-group-member')) || (getSessionUser().staffAccess && getSessionUser().staffAccess.groupManagement)
|
||||
meta(name='ol-user' data-type='json' content=user)
|
||||
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='number' content=groupSize)
|
||||
meta(name='ol-hasWriteAccess' data-type='boolean' content=hasWriteAccess)
|
||||
meta(
|
||||
name='ol-managedUsersActive'
|
||||
data-type='boolean'
|
||||
content=managedUsersActive
|
||||
)
|
||||
meta(name='ol-groupSSOActive' data-type='boolean' content=groupSSOActive)
|
||||
meta(
|
||||
name='ol-canUseAddSeatsFeature'
|
||||
data-type='boolean'
|
||||
content=canUseAddSeatsFeature
|
||||
)
|
||||
|
||||
block content
|
||||
main#subscription-manage-group-root.content.content-alt
|
||||
@@ -108,6 +108,7 @@
|
||||
"address_line_1": "",
|
||||
"address_second_line_optional": "",
|
||||
"adjust_column_width": "",
|
||||
"admin_titlecase": "",
|
||||
"advanced_reference_search_mode": "",
|
||||
"advancing_research_with": "",
|
||||
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
|
||||
@@ -756,8 +757,12 @@
|
||||
"go_to_account_settings": "",
|
||||
"go_to_code_location": "",
|
||||
"go_to_code_location_in_pdf": "",
|
||||
"go_to_first_page": "",
|
||||
"go_to_last_page": "",
|
||||
"go_to_next_page": "",
|
||||
"go_to_overleaf": "",
|
||||
"go_to_pdf_location_in_code": "",
|
||||
"go_to_previous_page": "",
|
||||
"go_to_settings": "",
|
||||
"go_to_subscriptions": "",
|
||||
"go_to_writefull": "",
|
||||
@@ -935,6 +940,7 @@
|
||||
"invite_more_members": "",
|
||||
"invite_not_accepted": "",
|
||||
"invite_resend_limit_hit": "",
|
||||
"invite_users": "",
|
||||
"invited_to_group": "",
|
||||
"invited_to_group_have_individual_subcription": "",
|
||||
"inviting": "",
|
||||
@@ -990,6 +996,7 @@
|
||||
"learn_more_about_email_reconfirmation": "",
|
||||
"learn_more_about_link_sharing": "",
|
||||
"learn_more_about_managed_users": "",
|
||||
"learn_more_about_roles_permissions": "",
|
||||
"leave": "",
|
||||
"leave_any_group_subscriptions": "",
|
||||
"leave_group": "",
|
||||
@@ -1005,6 +1012,9 @@
|
||||
"lets_get_those_premium_features": "",
|
||||
"lets_get_you_set_up": "",
|
||||
"library": "",
|
||||
"license": "",
|
||||
"license_allocated": "",
|
||||
"license_not_allocated": "",
|
||||
"licenses": "",
|
||||
"light_themes": "",
|
||||
"limited_document_history": "",
|
||||
@@ -1077,6 +1087,7 @@
|
||||
"manage_subscription": "",
|
||||
"manage_tag": "",
|
||||
"manage_template": "",
|
||||
"manage_users_subtext": "",
|
||||
"manage_your_ai_assist_add_on": "",
|
||||
"managed": "",
|
||||
"managed_user_accounts": "",
|
||||
@@ -1085,6 +1096,7 @@
|
||||
"managed_users_explanation": "",
|
||||
"managed_users_is_enabled": "",
|
||||
"managed_users_terms": "",
|
||||
"manager": "",
|
||||
"managers_management": "",
|
||||
"managing_your_subscription": "",
|
||||
"marked_as_resolved": "",
|
||||
@@ -1094,6 +1106,7 @@
|
||||
"maximum_files_uploaded_together": "",
|
||||
"maybe_later": "",
|
||||
"meet_the_new_dark_dashboard": "",
|
||||
"member": "",
|
||||
"members_added": "",
|
||||
"members_management": "",
|
||||
"mendeley": "",
|
||||
@@ -1259,6 +1272,7 @@
|
||||
"owned_by_x": "",
|
||||
"owner": "",
|
||||
"page_current": "",
|
||||
"page_x_of_n": "",
|
||||
"pagination_navigation": "",
|
||||
"papers": "",
|
||||
"papers_dynamic_sync_description": "",
|
||||
@@ -1638,6 +1652,7 @@
|
||||
"select_tag": "",
|
||||
"select_tax_id_type": "",
|
||||
"select_user": "",
|
||||
"select_user_role": "",
|
||||
"selected": "",
|
||||
"selection_deleted": "",
|
||||
"send": "",
|
||||
@@ -2143,6 +2158,7 @@
|
||||
"user_first_name_attribute": "",
|
||||
"user_has_left_organization_and_need_to_transfer_their_projects": "",
|
||||
"user_last_name_attribute": "",
|
||||
"user_management": "",
|
||||
"user_sessions": "",
|
||||
"using_latex": "",
|
||||
"using_premium_features": "",
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import classNames from 'classnames'
|
||||
import moment from 'moment'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { User } from '@ol-types/group-management/user'
|
||||
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLCard from '@/shared/components/ol/ol-card'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import OLFormSelect from '@/shared/components/ol/ol-form-select'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLTable from '@/shared/components/ol/ol-table'
|
||||
import OLTag from '@/shared/components/ol/ol-tag'
|
||||
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
import BackButton from './back-button'
|
||||
import { useGroupMembersContext } from '../context/group-members-context'
|
||||
|
||||
const getUserRole = (user: User) =>
|
||||
user.isEntityAdmin ? 'admin' : user.isEntityManager ? 'manager' : 'member'
|
||||
|
||||
export default function GroupUsers() {
|
||||
const { t } = useTranslation()
|
||||
const groupName = getMeta('ol-groupName')
|
||||
const groupId = getMeta('ol-groupId')
|
||||
const groupSize = getMeta('ol-groupSize')
|
||||
const canUseAddSeatsFeature = getMeta('ol-canUseAddSeatsFeature')
|
||||
const groupSSOActive = getMeta('ol-groupSSOActive')
|
||||
const managedUsersActive = getMeta('ol-managedUsersActive')
|
||||
|
||||
const {
|
||||
users,
|
||||
// selectedUsers,
|
||||
// addMembers,
|
||||
// removeMembers,
|
||||
// removeMemberLoading,
|
||||
// removeMemberError,
|
||||
// inviteMemberLoading,
|
||||
// inviteError,
|
||||
// memberAdded,
|
||||
// paths,
|
||||
} = useGroupMembersContext()
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const numPages = Math.ceil(users.length / 10)
|
||||
|
||||
const paginatedUsers = useMemo(() => {
|
||||
const firstUser = (page - 1) * 10
|
||||
const lastUser = Math.min(page * 10, users.length)
|
||||
return users.slice(firstUser, lastUser)
|
||||
}, [users, page])
|
||||
|
||||
const addedUsersSize = users.filter(user => user.isEntityMember).length
|
||||
|
||||
return (
|
||||
<div className="container group-users-container">
|
||||
<OLRow>
|
||||
<OLCol>
|
||||
<div className="group-heading">
|
||||
<BackButton
|
||||
href="/user/subscription"
|
||||
accessibilityLabel={t('back_to_subscription')}
|
||||
/>
|
||||
<h1 className="heading">{groupName || t('group_subscription')}</h1>
|
||||
</div>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
<OLCard>
|
||||
<OLRow className="justify-content-between">
|
||||
<OLCol xs="auto">
|
||||
<h2 className="page-title">{t('user_management')}</h2>
|
||||
<a href="/" className="learn-more">
|
||||
{t('learn_more_about_roles_permissions')}
|
||||
</a>
|
||||
</OLCol>
|
||||
<OLCol xs="auto" className="align-content-center">
|
||||
<OLButton>{t('invite_users')}</OLButton>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
|
||||
<OLRow className="license-info">
|
||||
<OLNotification
|
||||
type="info"
|
||||
content={
|
||||
users.length === 1
|
||||
? t('you_have_1_license_and_your_plan_supports_up_to_y', {
|
||||
groupSize,
|
||||
})
|
||||
: t('you_have_x_licenses_and_your_plan_supports_up_to_y', {
|
||||
addedUsersSize,
|
||||
groupSize,
|
||||
})
|
||||
}
|
||||
action={
|
||||
canUseAddSeatsFeature ? (
|
||||
<a
|
||||
href="/user/subscription/group/add-users"
|
||||
className={classNames({
|
||||
'btn btn-premium': addedUsersSize === groupSize,
|
||||
})}
|
||||
>
|
||||
{t('buy_more_licenses')}
|
||||
</a>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</OLRow>
|
||||
|
||||
<OLRow>
|
||||
<OLCol>
|
||||
<OLTable hover responsive bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<OLFormCheckbox />
|
||||
</th>
|
||||
<th>{t('email')}</th>
|
||||
<th>{t('name')}</th>
|
||||
<th>{t('last_active')}</th>
|
||||
<th>{t('role')}</th>
|
||||
<th className="text-center">{t('license')}</th>
|
||||
{groupSSOActive && (
|
||||
<th className="text-center">{t('sso')}</th>
|
||||
)}
|
||||
{managedUsersActive && (
|
||||
<th className="text-center">{t('managed')}</th>
|
||||
)}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedUsers.map(user => (
|
||||
<tr key={user.email} className="align-middle">
|
||||
<td>
|
||||
<OLFormCheckbox />
|
||||
</td>
|
||||
<td>{user.email}</td>
|
||||
<td className="text-nowrap">
|
||||
{user.first_name} {user.last_name}
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
{user.invite ? (
|
||||
<OLTag>{t('pending_invite')}</OLTag>
|
||||
) : (
|
||||
moment(user.last_active_at).format('Do MMM YYYY')
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<OLFormSelect
|
||||
aria-label={t('select_user_role')}
|
||||
name="user-role"
|
||||
defaultValue={getUserRole(user)}
|
||||
className="user-role-select"
|
||||
disabled
|
||||
>
|
||||
<option value="admin">{t('admin_titlecase')}</option>
|
||||
<option value="manager">{t('manager')}</option>
|
||||
<option value="member">{t('member')}</option>
|
||||
</OLFormSelect>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{user.isEntityMember ? (
|
||||
<MaterialIcon
|
||||
type="check"
|
||||
className="text-success"
|
||||
accessibilityLabel={t('license_allocated')}
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon
|
||||
type="close"
|
||||
className="text-danger"
|
||||
accessibilityLabel={t('license_not_allocated')}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{groupSSOActive && (
|
||||
<td className="text-center">
|
||||
{user.enrollment?.sso?.some(
|
||||
sso => sso.groupId === groupId
|
||||
) ? (
|
||||
<MaterialIcon
|
||||
type="check"
|
||||
className="text-success"
|
||||
accessibilityLabel={t('sso_active')}
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon
|
||||
type="close"
|
||||
className="text-danger"
|
||||
accessibilityLabel={t('sso_not_active')}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{managedUsersActive && (
|
||||
<td className="text-center">
|
||||
{user.enrollment?.managedBy === groupId ? (
|
||||
<MaterialIcon
|
||||
type="check"
|
||||
className="text-success"
|
||||
accessibilityLabel={t('managed')}
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon
|
||||
type="close"
|
||||
className="text-danger"
|
||||
accessibilityLabel={t('not_managed')}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>...</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</OLTable>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
<OLRow className="justify-content-between align-items-center">
|
||||
<OLCol xs="auto" className="ms-3">
|
||||
<p className="mb-0">
|
||||
{t('showing_x_out_of_n_users', {
|
||||
x: paginatedUsers.length,
|
||||
n: users.length,
|
||||
})}
|
||||
</p>
|
||||
</OLCol>
|
||||
<OLCol xs="auto" className="pagination-container">
|
||||
<OLButton
|
||||
variant="ghost"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(1)}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="first_page"
|
||||
accessibilityLabel={t('go_to_first_page')}
|
||||
/>
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="ghost"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(page => page - 1)}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="chevron_left"
|
||||
accessibilityLabel={t('go_to_previous_page')}
|
||||
/>
|
||||
</OLButton>
|
||||
<p className="d-inline-flex mb-0">
|
||||
{t('page_x_of_n', { x: page, n: numPages })}
|
||||
</p>
|
||||
<OLButton
|
||||
variant="ghost"
|
||||
disabled={page === numPages}
|
||||
onClick={() => setPage(page => page + 1)}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="chevron_right"
|
||||
accessibilityLabel={t('go_to_next_page')}
|
||||
/>
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="ghost"
|
||||
disabled={page === numPages}
|
||||
onClick={() => setPage(numPages)}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="last_page"
|
||||
accessibilityLabel={t('go_to_last_page')}
|
||||
/>
|
||||
</OLButton>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</OLCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useSubscriptionDashboardContext } from '../../context/subscription-dash
|
||||
import { RowLink } from './row-link'
|
||||
import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
function ManagedGroupAdministrator({
|
||||
subscription,
|
||||
@@ -93,6 +94,8 @@ export default function ManagedGroupSubscriptions() {
|
||||
|
||||
const { managedGroupSubscriptions } = useSubscriptionDashboardContext()
|
||||
|
||||
const combinedUserManagement = useFeatureFlag('combined-user-management')
|
||||
|
||||
if (!managedGroupSubscriptions) {
|
||||
return null
|
||||
}
|
||||
@@ -113,18 +116,30 @@ export default function ManagedGroupSubscriptions() {
|
||||
<ManagedGroupAdministrator subscription={subscription} />
|
||||
</p>
|
||||
<ul className="list-group p-0">
|
||||
<RowLink
|
||||
href={`/manage/groups/${subscription._id}/members`}
|
||||
heading={t('group_members')}
|
||||
subtext={t('manage_group_members_subtext')}
|
||||
icon="groups"
|
||||
/>
|
||||
<RowLink
|
||||
href={`/manage/groups/${subscription._id}/managers`}
|
||||
heading={t('group_managers')}
|
||||
subtext={t('manage_managers_subtext')}
|
||||
icon="manage_accounts"
|
||||
/>
|
||||
{combinedUserManagement && (
|
||||
<RowLink
|
||||
href={`/manage/groups/${subscription._id}/users`}
|
||||
heading={t('user_management')}
|
||||
subtext={t('manage_users_subtext')}
|
||||
icon="groups"
|
||||
/>
|
||||
)}
|
||||
{!combinedUserManagement && (
|
||||
<>
|
||||
<RowLink
|
||||
href={`/manage/groups/${subscription._id}/members`}
|
||||
heading={t('group_members')}
|
||||
subtext={t('manage_group_members_subtext')}
|
||||
icon="groups"
|
||||
/>
|
||||
<RowLink
|
||||
href={`/manage/groups/${subscription._id}/managers`}
|
||||
heading={t('group_managers')}
|
||||
subtext={t('manage_managers_subtext')}
|
||||
icon="manage_accounts"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{groupSettingsEnabledFor?.includes(subscription._id) && (
|
||||
<GroupSettingsButton subscription={subscription} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../base'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import GroupUsers from '../../../../features/group-management/components/group-users'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
|
||||
const element = document.getElementById('subscription-manage-group-root')
|
||||
if (element) {
|
||||
const root = createRoot(element)
|
||||
root.render(
|
||||
<GroupMembersProvider>
|
||||
<GroupUsers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
}
|
||||
@@ -48,3 +48,4 @@
|
||||
@import 'group-members';
|
||||
@import 'upgrade-benefits';
|
||||
@import 'labeled-divider';
|
||||
@import 'group-users';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.group-users-container {
|
||||
h2.page-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a.learn-more {
|
||||
@include body-sm;
|
||||
}
|
||||
|
||||
.license-info {
|
||||
margin-top: var(--spacing-08);
|
||||
margin-bottom: var(--spacing-08);
|
||||
}
|
||||
|
||||
.pagination-container .btn-ghost {
|
||||
--bs-btn-hover-bg: none;
|
||||
--bs-btn-disabled-bg: none;
|
||||
}
|
||||
|
||||
.user-role-select {
|
||||
min-width: 110px;
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@
|
||||
"adjust_column_width": "Adjust column width",
|
||||
"admin": "admin",
|
||||
"admin_panel": "Admin panel",
|
||||
"admin_titlecase": "Admin",
|
||||
"admin_user_created_message": "Created admin user, <a href=\"__link__\">Log in here</a> to continue",
|
||||
"administration_and_security": "Administration and security",
|
||||
"advanced_reference_search": "Advanced <0>reference search</0>",
|
||||
@@ -1186,6 +1187,7 @@
|
||||
"invite_not_valid": "This is not a valid project invite",
|
||||
"invite_not_valid_description": "The invite may have expired. Please contact the project owner",
|
||||
"invite_resend_limit_hit": "The invite resend limit hit",
|
||||
"invite_users": "Invite users",
|
||||
"invited_to_group": "<0>__inviterName__</0> has invited you to join a group subscription on __appName__",
|
||||
"invited_to_group_have_individual_subcription": "__inviterName__ has invited you to join a group __appName__ subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?",
|
||||
"invited_to_group_login": "To accept this invitation you need to log in as __emailAddress__.",
|
||||
@@ -1275,6 +1277,7 @@
|
||||
"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_about_roles_permissions": "Learn more about Roles & Permissions",
|
||||
"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>",
|
||||
"leave_group": "Leave group",
|
||||
@@ -1293,6 +1296,8 @@
|
||||
"libraries": "Libraries",
|
||||
"library": "Library",
|
||||
"license": "License",
|
||||
"license_allocated": "License allocated",
|
||||
"license_not_allocated": "License not allocated",
|
||||
"licenses": "Licenses",
|
||||
"light_themes": "Light themes",
|
||||
"limited_document_history": "Limited document history",
|
||||
@@ -1394,6 +1399,7 @@
|
||||
"manage_subscription": "Manage subscription",
|
||||
"manage_tag": "Manage tag",
|
||||
"manage_template": "Manage template",
|
||||
"manage_users_subtext": "Add or remove members and assign roles",
|
||||
"manage_your_ai_assist_add_on": "Manage your AI Assist add-on",
|
||||
"managed": "Managed",
|
||||
"managed_user_accounts": "Managed user accounts",
|
||||
@@ -1403,6 +1409,7 @@
|
||||
"managed_users_gives_gives_you_more_control_over_your_group": "Managed Users gives you more control over your group’s use of __appName__. It ensures tighter management of user access and deletion and allows you to keep control of your projects when someone leaves the group.",
|
||||
"managed_users_is_enabled": "Managed Users is enabled",
|
||||
"managed_users_terms": "To use the Managed Users feature, you must agree to the latest version of our customer terms at <0>__link__</0> on behalf of your organization by selecting \"I agree\" below. These terms will then apply to your organization’s use of Overleaf in place of any previously agreed Overleaf terms. The exception to this is where we have a signed agreement in place with you, in which case that signed agreement will continue to govern. Please keep a copy for your records.",
|
||||
"manager": "Manager",
|
||||
"managers_cannot_remove_admin": "Admins cannot be removed",
|
||||
"managers_cannot_remove_self": "Managers cannot remove themselves",
|
||||
"managers_management": "Managers management",
|
||||
@@ -1416,6 +1423,7 @@
|
||||
"may": "May",
|
||||
"maybe_later": "Maybe later",
|
||||
"meet_the_new_dark_dashboard": "Meet the new dark dashboard",
|
||||
"member": "Member",
|
||||
"member_picker": "Select number of users for group plan",
|
||||
"members_added": "Member(s) added.",
|
||||
"members_management": "Members management",
|
||||
@@ -1639,6 +1647,7 @@
|
||||
"owner": "Owner",
|
||||
"page_current": "Page __page__, Current Page",
|
||||
"page_not_found": "Page Not Found",
|
||||
"page_x_of_n": "Page __x__ of __n__",
|
||||
"pagination_navigation": "Pagination Navigation",
|
||||
"papers": "Papers",
|
||||
"papers_dynamic_sync_description": "With the Papers integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Papers library directly from __appName__.",
|
||||
@@ -2105,6 +2114,7 @@
|
||||
"select_tag": "Select tag __tagName__",
|
||||
"select_tax_id_type": "Select tax ID type",
|
||||
"select_user": "Select user",
|
||||
"select_user_role": "Select user role",
|
||||
"selected": "Selected",
|
||||
"selected_by_overleaf_staff": "Selected by Overleaf staff",
|
||||
"selection_deleted": "Selection deleted",
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import GroupUsers from '@/features/group-management/components/group-users'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../types/group-management/user'
|
||||
|
||||
const GROUP_ID = '777fff777fff'
|
||||
|
||||
describe('GroupUsers', function () {
|
||||
function mountGroupUsersProvider() {
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupUsers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('renders the user management page', function () {
|
||||
// Admin user who is also a Member (has a license allocated)
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
// Manager who is not a Member (does not have a license allocated)
|
||||
const BOBBY_LAPOINTE: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
isEntityManager: true,
|
||||
}
|
||||
// User with a pending invite
|
||||
const CLAIRE_JENNINGS: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: true,
|
||||
}
|
||||
// User in the Members list
|
||||
const DAVID_JONES: User = {
|
||||
_id: 'badcab391832',
|
||||
first_name: 'David',
|
||||
last_name: 'Jones',
|
||||
email: 'david.jones@test.com',
|
||||
last_active_at: new Date('2023-01-04'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
DAVID_JONES,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-hasWriteAccess', true)
|
||||
})
|
||||
|
||||
mountGroupUsersProvider()
|
||||
})
|
||||
|
||||
it('displays license information correctly', function () {
|
||||
cy.get('.license-info').contains(
|
||||
'You have allocated 2 licenses and your plan supports up to 10'
|
||||
)
|
||||
})
|
||||
|
||||
it('displays correct user information', function () {
|
||||
cy.get('table tbody').within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('John Doe')
|
||||
cy.contains('15th Jan 2023')
|
||||
cy.findByRole('combobox', { name: 'Select user role' }).should(
|
||||
'have.value',
|
||||
'admin'
|
||||
)
|
||||
cy.contains('License allocated')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.findByRole('combobox', { name: 'Select user role' }).should(
|
||||
'have.value',
|
||||
'manager'
|
||||
)
|
||||
cy.contains('License not allocated')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.contains('Claire Jennings')
|
||||
cy.contains('Pending invite')
|
||||
cy.findByRole('combobox', { name: 'Select user role' }).should(
|
||||
'have.value',
|
||||
'member'
|
||||
)
|
||||
cy.contains('License not allocated')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.contains('david.jones@test.com')
|
||||
cy.contains('David Jones')
|
||||
cy.contains('4th Jan 2023')
|
||||
cy.findByRole('combobox', { name: 'Select user role' }).should(
|
||||
'have.value',
|
||||
'member'
|
||||
)
|
||||
cy.contains('License allocated')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with flexible group licensing enabled', function () {
|
||||
beforeEach(function () {
|
||||
this.JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', true)
|
||||
win.metaAttributesCache.set('ol-hasWriteAccess', true)
|
||||
win.metaAttributesCache.set('ol-users', [this.JOHN_DOE])
|
||||
})
|
||||
})
|
||||
|
||||
it('shows buy more licenses link when not at group capacity', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
})
|
||||
mountGroupUsersProvider()
|
||||
cy.findByRole('link', { name: 'Buy more licenses' })
|
||||
.should('exist')
|
||||
.should('not.have.class', 'btn')
|
||||
})
|
||||
|
||||
it('shows buy more licenses as a premium button when at group capacity', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSize', 1)
|
||||
})
|
||||
mountGroupUsersProvider()
|
||||
cy.findByRole('link', { name: 'Buy more licenses' })
|
||||
.should('exist')
|
||||
.should('have.class', 'btn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Group SSO enabled', function () {
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
}
|
||||
const SSO_USER: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'SSO',
|
||||
last_name: 'User',
|
||||
email: 'sso.user@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-02'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, SSO_USER])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
win.metaAttributesCache.set('ol-hasWriteAccess', true)
|
||||
})
|
||||
|
||||
mountGroupUsersProvider()
|
||||
})
|
||||
|
||||
it('displays the SSO column', function () {
|
||||
cy.get('table thead').within(() => {
|
||||
cy.contains('SSO')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows SSO status correctly', function () {
|
||||
cy.get('table tbody').within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('SSO not active')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('sso.user@test.com')
|
||||
cy.contains('SSO active')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users enabled', function () {
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
}
|
||||
const MANAGED_USER: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Managed',
|
||||
last_name: 'User',
|
||||
email: 'managed.user@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-02'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, MANAGED_USER])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
win.metaAttributesCache.set('ol-hasWriteAccess', true)
|
||||
})
|
||||
|
||||
mountGroupUsersProvider()
|
||||
})
|
||||
|
||||
it('displays the Managed column', function () {
|
||||
cy.get('table thead').within(() => {
|
||||
cy.contains('Managed')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows managed status correctly', function () {
|
||||
cy.get('table tbody').within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('Not managed')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('managed.user@test.com')
|
||||
cy.contains('Managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination', function () {
|
||||
beforeEach(function () {
|
||||
// Create 25 users to test pagination
|
||||
const users = Array(25)
|
||||
.fill({})
|
||||
.map((_, i) => ({
|
||||
_id: `user${i}`,
|
||||
first_name: `User${i}`,
|
||||
last_name: `Test${i}`,
|
||||
email: `user${i}@test.com`,
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: false,
|
||||
isEntityMember: true,
|
||||
}))
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 30)
|
||||
win.metaAttributesCache.set('ol-users', users)
|
||||
win.metaAttributesCache.set('ol-hasWriteAccess', true)
|
||||
})
|
||||
|
||||
mountGroupUsersProvider()
|
||||
})
|
||||
|
||||
it('displays the first page correctly', function () {
|
||||
cy.contains('Showing 10 out of 25 users')
|
||||
cy.contains('Page 1 of 3')
|
||||
cy.contains('User0 Test0')
|
||||
cy.get('table tbody tr').should('have.length', 10)
|
||||
})
|
||||
|
||||
it('first page and previous buttons are disabled on first page', function () {
|
||||
cy.findByRole('button', { name: 'Go to first page' }).should(
|
||||
'be.disabled'
|
||||
)
|
||||
cy.findByRole('button', { name: 'Go to previous page' }).should(
|
||||
'be.disabled'
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to next page', function () {
|
||||
cy.findByRole('button', { name: 'Go to next page' }).click()
|
||||
|
||||
cy.contains('Page 2 of 3')
|
||||
cy.contains('User10 Test10')
|
||||
cy.findByText('User0 Test0').should('not.exist')
|
||||
cy.get('table tbody tr').should('have.length', 10)
|
||||
})
|
||||
|
||||
it('navigates to last page', function () {
|
||||
cy.findByRole('button', { name: 'Go to last page' }).click()
|
||||
|
||||
cy.contains('Page 3 of 3')
|
||||
cy.contains('Showing 5 out of 25 users')
|
||||
cy.contains('User20 Test20')
|
||||
cy.findByText('User0 Test0').should('not.exist')
|
||||
cy.get('table tbody tr').should('have.length', 5)
|
||||
})
|
||||
|
||||
it('last page and next buttons are disabled on last page', function () {
|
||||
cy.findByRole('button', { name: 'Go to last page' }).click()
|
||||
cy.findByRole('button', { name: 'Go to last page' }).should('be.disabled')
|
||||
cy.findByRole('button', { name: 'Go to next page' }).should('be.disabled')
|
||||
})
|
||||
|
||||
it('navigates to first page', function () {
|
||||
// Navigate to last page so first page button works
|
||||
cy.findByRole('button', { name: 'Go to last page' }).click()
|
||||
|
||||
cy.findByRole('button', { name: 'Go to first page' }).click()
|
||||
cy.contains('Page 1 of 3')
|
||||
})
|
||||
|
||||
it('navigates to previous page', function () {
|
||||
// Navigate to last page so previous page button works
|
||||
cy.findByRole('button', { name: 'Go to last page' }).click()
|
||||
|
||||
cy.findByRole('button', { name: 'Go to previous page' }).click()
|
||||
|
||||
cy.contains('Page 2 of 3')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,4 +19,6 @@ export type User = {
|
||||
last_active_at: Date
|
||||
enrollment?: UserEnrollment
|
||||
isEntityAdmin?: boolean
|
||||
isEntityManager?: boolean
|
||||
isEntityMember?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user