From ebdb0b12cf9768760b3bedd075aea904dcf363ff Mon Sep 17 00:00:00 2001
From: MoxAmber
Date: Wed, 25 Feb 2026 12:40:40 +0000
Subject: [PATCH] Merge pull request #30007 from overleaf/as-user-management-ro
[web] Initial read-only user management page
GitOrigin-RevId: f50d2377b855e6541b30f8f946aecb59bf08e3bc
---
.../Subscription/SubscriptionController.mjs | 5 +
.../UserMembershipController.mjs | 54 +++
.../UserMembershipEntityConfigs.mjs | 16 +
.../UserMembership/UserMembershipHandler.mjs | 14 +
.../UserMembershipMiddleware.mjs | 1 +
.../UserMembership/UserMembershipRouter.mjs | 5 +
.../user_membership/group-users-react.pug | 27 ++
.../web/frontend/extracted-translations.json | 16 +
.../components/group-users.tsx | 282 +++++++++++++
.../dashboard/managed-group-subscriptions.tsx | 39 +-
.../group-management/group-users.tsx | 14 +
.../frontend/stylesheets/components/all.scss | 1 +
.../stylesheets/components/group-users.scss | 23 ++
services/web/locales/en.json | 10 +
.../components/group-users.spec.tsx | 385 ++++++++++++++++++
services/web/types/group-management/user.ts | 2 +
16 files changed, 882 insertions(+), 12 deletions(-)
create mode 100644 services/web/app/views/user_membership/group-users-react.pug
create mode 100644 services/web/frontend/js/features/group-management/components/group-users.tsx
create mode 100644 services/web/frontend/js/pages/user/subscription/group-management/group-users.tsx
create mode 100644 services/web/frontend/stylesheets/components/group-users.scss
create mode 100644 services/web/test/frontend/features/group-management/components/group-users.spec.tsx
diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs
index 7634e712e8..820023d8a8 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs
@@ -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,
diff --git a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs
index 9df369c5ac..cd3cf1033a 100644
--- a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs
+++ b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs
@@ -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),
diff --git a/services/web/app/src/Features/UserMembership/UserMembershipEntityConfigs.mjs b/services/web/app/src/Features/UserMembership/UserMembershipEntityConfigs.mjs
index 6b20041b03..47ab424f04 100644
--- a/services/web/app/src/Features/UserMembership/UserMembershipEntityConfigs.mjs
+++ b/services/web/app/src/Features/UserMembership/UserMembershipEntityConfigs.mjs
@@ -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,
diff --git a/services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs b/services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs
index 2eb1d5721a..9fd4c82b0e 100644
--- a/services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs
+++ b/services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs
@@ -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
diff --git a/services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs b/services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs
index 6bea53c0a1..1e5af050a6 100644
--- a/services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs
+++ b/services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs
@@ -253,6 +253,7 @@ const ObjectIdEntitySchema = z.object({
'groupAdmin',
'groupManagers',
'groupMember',
+ 'groupUsers',
]),
params: z.object({
id: zz.coercedObjectId(),
diff --git a/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs b/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs
index e590e0d174..8103c7a002 100644
--- a/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs
+++ b/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs
@@ -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(
diff --git a/services/web/app/views/user_membership/group-users-react.pug b/services/web/app/views/user_membership/group-users-react.pug
new file mode 100644
index 0000000000..89b59f3e67
--- /dev/null
+++ b/services/web/app/views/user_membership/group-users-react.pug
@@ -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
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index f59b6e3a36..40fa21d6a9 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -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": "",
diff --git a/services/web/frontend/js/features/group-management/components/group-users.tsx b/services/web/frontend/js/features/group-management/components/group-users.tsx
new file mode 100644
index 0000000000..01881b9b17
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/group-users.tsx
@@ -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 (
+
+
+
+
+
+
{groupName || t('group_subscription')}
+
+
+
+
+
+
+ {t('user_management')}
+
+ {t('learn_more_about_roles_permissions')}
+
+
+
+ {t('invite_users')}
+
+
+
+
+
+ {t('buy_more_licenses')}
+
+ ) : undefined
+ }
+ />
+
+
+
+
+
+
+
+ |
+
+ |
+ {t('email')} |
+ {t('name')} |
+ {t('last_active')} |
+ {t('role')} |
+ {t('license')} |
+ {groupSSOActive && (
+ {t('sso')} |
+ )}
+ {managedUsersActive && (
+ {t('managed')} |
+ )}
+ |
+
+
+
+ {paginatedUsers.map(user => (
+
+ |
+
+ |
+ {user.email} |
+
+ {user.first_name} {user.last_name}
+ |
+
+ {user.invite ? (
+ {t('pending_invite')}
+ ) : (
+ moment(user.last_active_at).format('Do MMM YYYY')
+ )}
+ |
+
+
+
+
+
+
+ |
+
+ {user.isEntityMember ? (
+
+ ) : (
+
+ )}
+ |
+ {groupSSOActive && (
+
+ {user.enrollment?.sso?.some(
+ sso => sso.groupId === groupId
+ ) ? (
+
+ ) : (
+
+ )}
+ |
+ )}
+ {managedUsersActive && (
+
+ {user.enrollment?.managedBy === groupId ? (
+
+ ) : (
+
+ )}
+ |
+ )}
+ ... |
+
+ ))}
+
+
+
+
+
+
+
+ {t('showing_x_out_of_n_users', {
+ x: paginatedUsers.length,
+ n: users.length,
+ })}
+
+
+
+ setPage(1)}
+ >
+
+
+ setPage(page => page - 1)}
+ >
+
+
+
+ {t('page_x_of_n', { x: page, n: numPages })}
+
+ setPage(page => page + 1)}
+ >
+
+
+ setPage(numPages)}
+ >
+
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
index 1ebe224821..75fc5ab458 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
@@ -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() {
-
-
+ {combinedUserManagement && (
+
+ )}
+ {!combinedUserManagement && (
+ <>
+
+
+ >
+ )}
{groupSettingsEnabledFor?.includes(subscription._id) && (
)}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/group-users.tsx b/services/web/frontend/js/pages/user/subscription/group-management/group-users.tsx
new file mode 100644
index 0000000000..271c382be6
--- /dev/null
+++ b/services/web/frontend/js/pages/user/subscription/group-management/group-users.tsx
@@ -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(
+
+
+
+ )
+}
diff --git a/services/web/frontend/stylesheets/components/all.scss b/services/web/frontend/stylesheets/components/all.scss
index 55371d978d..6a093459a0 100644
--- a/services/web/frontend/stylesheets/components/all.scss
+++ b/services/web/frontend/stylesheets/components/all.scss
@@ -48,3 +48,4 @@
@import 'group-members';
@import 'upgrade-benefits';
@import 'labeled-divider';
+@import 'group-users';
diff --git a/services/web/frontend/stylesheets/components/group-users.scss b/services/web/frontend/stylesheets/components/group-users.scss
new file mode 100644
index 0000000000..df21336e36
--- /dev/null
+++ b/services/web/frontend/stylesheets/components/group-users.scss
@@ -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;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index ba25ad0801..5aa0dd8351 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -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, Log in here to continue",
"administration_and_security": "Administration and security",
"advanced_reference_search": "Advanced <0>reference search0>",
@@ -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 more0> 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",
diff --git a/services/web/test/frontend/features/group-management/components/group-users.spec.tsx b/services/web/test/frontend/features/group-management/components/group-users.spec.tsx
new file mode 100644
index 0000000000..b06d5abd20
--- /dev/null
+++ b/services/web/test/frontend/features/group-management/components/group-users.spec.tsx
@@ -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(
+
+
+
+ )
+ }
+
+ 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')
+ })
+ })
+})
diff --git a/services/web/types/group-management/user.ts b/services/web/types/group-management/user.ts
index a436cd83a0..902abde9ca 100644
--- a/services/web/types/group-management/user.ts
+++ b/services/web/types/group-management/user.ts
@@ -19,4 +19,6 @@ export type User = {
last_active_at: Date
enrollment?: UserEnrollment
isEntityAdmin?: boolean
+ isEntityManager?: boolean
+ isEntityMember?: boolean
}