Bladeren bron

Merge pull request #30007 from overleaf/as-user-management-ro

[web] Initial read-only user management page

GitOrigin-RevId: f50d2377b855e6541b30f8f946aecb59bf08e3bc
MoxAmber 5 maanden geleden
bovenliggende
commit
ebdb0b12cf

+ 5 - 0
services/web/app/src/Features/Subscription/SubscriptionController.mjs

@@ -175,6 +175,11 @@ function formatGroupPlansDataForDash() {
 async function userSubscriptionPage(req, res) {
 async function userSubscriptionPage(req, res) {
   const user = SessionManager.getSessionUser(req.session)
   const user = SessionManager.getSessionUser(req.session)
   await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription')
   await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription')
+  await SplitTestHandler.promises.getAssignment(
+    req,
+    res,
+    'combined-user-management'
+  )
 
 
   const groupPricingDiscount = await SplitTestHandler.promises.getAssignment(
   const groupPricingDiscount = await SplitTestHandler.promises.getAssignment(
     req,
     req,

+ 54 - 0
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 RecurlyClient from '../Subscription/RecurlyClient.mjs'
 import Modules from '../../infrastructure/Modules.mjs'
 import Modules from '../../infrastructure/Modules.mjs'
 import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
 import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
+import _ from 'lodash'
 
 
 async function manageGroupMembers(req, res, next) {
 async function manageGroupMembers(req, res, next) {
   const { entity: subscription, entityConfig } = req
   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) {
 async function exportCsv(req, res) {
   let ssoEnabled
   let ssoEnabled
   const { entity, entityConfig } = req
   const { entity, entityConfig } = req
@@ -284,6 +337,7 @@ async function create(req, res) {
 export default {
 export default {
   manageGroupMembers: expressify(manageGroupMembers),
   manageGroupMembers: expressify(manageGroupMembers),
   manageGroupManagers: expressify(manageGroupManagers),
   manageGroupManagers: expressify(manageGroupManagers),
+  manageGroupUsers: expressify(manageGroupUsers),
   manageInstitutionManagers: expressify(manageInstitutionManagers),
   manageInstitutionManagers: expressify(manageInstitutionManagers),
   managePublisherManagers: expressify(managePublisherManagers),
   managePublisherManagers: expressify(managePublisherManagers),
   add: expressify(add),
   add: expressify(add),

+ 16 - 0
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: {
   groupMember: {
     modelName: 'Subscription',
     modelName: 'Subscription',
     readOnly: true,
     readOnly: true,

+ 14 - 0
services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs

@@ -125,6 +125,20 @@ async function getPopulatedListOfMembers(entity, attributes) {
     ) {
     ) {
       user.isEntityAdmin = true
       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
   return users

+ 1 - 0
services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs

@@ -253,6 +253,7 @@ const ObjectIdEntitySchema = z.object({
     'groupAdmin',
     'groupAdmin',
     'groupManagers',
     'groupManagers',
     'groupMember',
     'groupMember',
+    'groupUsers',
   ]),
   ]),
   params: z.object({
   params: z.object({
     id: zz.coercedObjectId(),
     id: zz.coercedObjectId(),

+ 5 - 0
services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs

@@ -52,6 +52,11 @@ export default {
       RateLimiterMiddleware.rateLimit(rateLimiters.exportTeamCsv),
       RateLimiterMiddleware.rateLimit(rateLimiters.exportTeamCsv),
       UserMembershipController.exportCsv
       UserMembershipController.exportCsv
     )
     )
+    webRouter.get(
+      '/manage/groups/:id/users',
+      UserMembershipMiddleware.requireEntityAccessOrAdminAccess('groupUsers'),
+      UserMembershipController.manageGroupUsers
+    )
 
 
     // group managers routes
     // group managers routes
     webRouter.get(
     webRouter.get(

+ 27 - 0
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

+ 16 - 0
services/web/frontend/extracted-translations.json

@@ -108,6 +108,7 @@
   "address_line_1": "",
   "address_line_1": "",
   "address_second_line_optional": "",
   "address_second_line_optional": "",
   "adjust_column_width": "",
   "adjust_column_width": "",
+  "admin_titlecase": "",
   "advanced_reference_search_mode": "",
   "advanced_reference_search_mode": "",
   "advancing_research_with": "",
   "advancing_research_with": "",
   "after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
   "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_account_settings": "",
   "go_to_code_location": "",
   "go_to_code_location": "",
   "go_to_code_location_in_pdf": "",
   "go_to_code_location_in_pdf": "",
+  "go_to_first_page": "",
+  "go_to_last_page": "",
+  "go_to_next_page": "",
   "go_to_overleaf": "",
   "go_to_overleaf": "",
   "go_to_pdf_location_in_code": "",
   "go_to_pdf_location_in_code": "",
+  "go_to_previous_page": "",
   "go_to_settings": "",
   "go_to_settings": "",
   "go_to_subscriptions": "",
   "go_to_subscriptions": "",
   "go_to_writefull": "",
   "go_to_writefull": "",
@@ -935,6 +940,7 @@
   "invite_more_members": "",
   "invite_more_members": "",
   "invite_not_accepted": "",
   "invite_not_accepted": "",
   "invite_resend_limit_hit": "",
   "invite_resend_limit_hit": "",
+  "invite_users": "",
   "invited_to_group": "",
   "invited_to_group": "",
   "invited_to_group_have_individual_subcription": "",
   "invited_to_group_have_individual_subcription": "",
   "inviting": "",
   "inviting": "",
@@ -990,6 +996,7 @@
   "learn_more_about_email_reconfirmation": "",
   "learn_more_about_email_reconfirmation": "",
   "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": "",
   "leave": "",
   "leave": "",
   "leave_any_group_subscriptions": "",
   "leave_any_group_subscriptions": "",
   "leave_group": "",
   "leave_group": "",
@@ -1005,6 +1012,9 @@
   "lets_get_those_premium_features": "",
   "lets_get_those_premium_features": "",
   "lets_get_you_set_up": "",
   "lets_get_you_set_up": "",
   "library": "",
   "library": "",
+  "license": "",
+  "license_allocated": "",
+  "license_not_allocated": "",
   "licenses": "",
   "licenses": "",
   "light_themes": "",
   "light_themes": "",
   "limited_document_history": "",
   "limited_document_history": "",
@@ -1077,6 +1087,7 @@
   "manage_subscription": "",
   "manage_subscription": "",
   "manage_tag": "",
   "manage_tag": "",
   "manage_template": "",
   "manage_template": "",
+  "manage_users_subtext": "",
   "manage_your_ai_assist_add_on": "",
   "manage_your_ai_assist_add_on": "",
   "managed": "",
   "managed": "",
   "managed_user_accounts": "",
   "managed_user_accounts": "",
@@ -1085,6 +1096,7 @@
   "managed_users_explanation": "",
   "managed_users_explanation": "",
   "managed_users_is_enabled": "",
   "managed_users_is_enabled": "",
   "managed_users_terms": "",
   "managed_users_terms": "",
+  "manager": "",
   "managers_management": "",
   "managers_management": "",
   "managing_your_subscription": "",
   "managing_your_subscription": "",
   "marked_as_resolved": "",
   "marked_as_resolved": "",
@@ -1094,6 +1106,7 @@
   "maximum_files_uploaded_together": "",
   "maximum_files_uploaded_together": "",
   "maybe_later": "",
   "maybe_later": "",
   "meet_the_new_dark_dashboard": "",
   "meet_the_new_dark_dashboard": "",
+  "member": "",
   "members_added": "",
   "members_added": "",
   "members_management": "",
   "members_management": "",
   "mendeley": "",
   "mendeley": "",
@@ -1259,6 +1272,7 @@
   "owned_by_x": "",
   "owned_by_x": "",
   "owner": "",
   "owner": "",
   "page_current": "",
   "page_current": "",
+  "page_x_of_n": "",
   "pagination_navigation": "",
   "pagination_navigation": "",
   "papers": "",
   "papers": "",
   "papers_dynamic_sync_description": "",
   "papers_dynamic_sync_description": "",
@@ -1638,6 +1652,7 @@
   "select_tag": "",
   "select_tag": "",
   "select_tax_id_type": "",
   "select_tax_id_type": "",
   "select_user": "",
   "select_user": "",
+  "select_user_role": "",
   "selected": "",
   "selected": "",
   "selection_deleted": "",
   "selection_deleted": "",
   "send": "",
   "send": "",
@@ -2143,6 +2158,7 @@
   "user_first_name_attribute": "",
   "user_first_name_attribute": "",
   "user_has_left_organization_and_need_to_transfer_their_projects": "",
   "user_has_left_organization_and_need_to_transfer_their_projects": "",
   "user_last_name_attribute": "",
   "user_last_name_attribute": "",
+  "user_management": "",
   "user_sessions": "",
   "user_sessions": "",
   "using_latex": "",
   "using_latex": "",
   "using_premium_features": "",
   "using_premium_features": "",

+ 282 - 0
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 (
+    <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>
+  )
+}

+ 27 - 12
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 { RowLink } from './row-link'
 import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
 import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { sendMB } from '@/infrastructure/event-tracking'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 
 function ManagedGroupAdministrator({
 function ManagedGroupAdministrator({
   subscription,
   subscription,
@@ -93,6 +94,8 @@ export default function ManagedGroupSubscriptions() {
 
 
   const { managedGroupSubscriptions } = useSubscriptionDashboardContext()
   const { managedGroupSubscriptions } = useSubscriptionDashboardContext()
 
 
+  const combinedUserManagement = useFeatureFlag('combined-user-management')
+
   if (!managedGroupSubscriptions) {
   if (!managedGroupSubscriptions) {
     return null
     return null
   }
   }
@@ -113,18 +116,30 @@ export default function ManagedGroupSubscriptions() {
               <ManagedGroupAdministrator subscription={subscription} />
               <ManagedGroupAdministrator subscription={subscription} />
             </p>
             </p>
             <ul className="list-group p-0">
             <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) && (
               {groupSettingsEnabledFor?.includes(subscription._id) && (
                 <GroupSettingsButton subscription={subscription} />
                 <GroupSettingsButton subscription={subscription} />
               )}
               )}

+ 14 - 0
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(
+    <GroupMembersProvider>
+      <GroupUsers />
+    </GroupMembersProvider>
+  )
+}

+ 1 - 0
services/web/frontend/stylesheets/components/all.scss

@@ -48,3 +48,4 @@
 @import 'group-members';
 @import 'group-members';
 @import 'upgrade-benefits';
 @import 'upgrade-benefits';
 @import 'labeled-divider';
 @import 'labeled-divider';
+@import 'group-users';

+ 23 - 0
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;
+  }
+}

+ 10 - 0
services/web/locales/en.json

@@ -127,6 +127,7 @@
   "adjust_column_width": "Adjust column width",
   "adjust_column_width": "Adjust column width",
   "admin": "admin",
   "admin": "admin",
   "admin_panel": "Admin panel",
   "admin_panel": "Admin panel",
+  "admin_titlecase": "Admin",
   "admin_user_created_message": "Created admin user, <a href=\"__link__\">Log in here</a> to continue",
   "admin_user_created_message": "Created admin user, <a href=\"__link__\">Log in here</a> to continue",
   "administration_and_security": "Administration and security",
   "administration_and_security": "Administration and security",
   "advanced_reference_search": "Advanced <0>reference search</0>",
   "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": "This is not a valid project invite",
   "invite_not_valid_description": "The invite may have expired. Please contact the project owner",
   "invite_not_valid_description": "The invite may have expired. Please contact the project owner",
   "invite_resend_limit_hit": "The invite resend limit hit",
   "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": "<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_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__.",
   "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_emails": "<0>Learn more</0> about managing your __appName__ emails.",
   "learn_more_about_link_sharing": "Learn more about Link Sharing",
   "learn_more_about_link_sharing": "Learn more about Link Sharing",
   "learn_more_about_managed_users": "Learn more about Managed Users.",
   "learn_more_about_managed_users": "Learn more about Managed Users.",
+  "learn_more_about_roles_permissions": "Learn more about Roles & Permissions",
   "leave": "Leave",
   "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_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",
   "leave_group": "Leave group",
@@ -1293,6 +1296,8 @@
   "libraries": "Libraries",
   "libraries": "Libraries",
   "library": "Library",
   "library": "Library",
   "license": "License",
   "license": "License",
+  "license_allocated": "License allocated",
+  "license_not_allocated": "License not allocated",
   "licenses": "Licenses",
   "licenses": "Licenses",
   "light_themes": "Light themes",
   "light_themes": "Light themes",
   "limited_document_history": "Limited document history",
   "limited_document_history": "Limited document history",
@@ -1394,6 +1399,7 @@
   "manage_subscription": "Manage subscription",
   "manage_subscription": "Manage subscription",
   "manage_tag": "Manage tag",
   "manage_tag": "Manage tag",
   "manage_template": "Manage template",
   "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",
   "manage_your_ai_assist_add_on": "Manage your AI Assist add-on",
   "managed": "Managed",
   "managed": "Managed",
   "managed_user_accounts": "Managed user accounts",
   "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_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_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.",
   "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_admin": "Admins cannot be removed",
   "managers_cannot_remove_self": "Managers cannot remove themselves",
   "managers_cannot_remove_self": "Managers cannot remove themselves",
   "managers_management": "Managers management",
   "managers_management": "Managers management",
@@ -1416,6 +1423,7 @@
   "may": "May",
   "may": "May",
   "maybe_later": "Maybe later",
   "maybe_later": "Maybe later",
   "meet_the_new_dark_dashboard": "Meet the new dark dashboard",
   "meet_the_new_dark_dashboard": "Meet the new dark dashboard",
+  "member": "Member",
   "member_picker": "Select number of users for group plan",
   "member_picker": "Select number of users for group plan",
   "members_added": "Member(s) added.",
   "members_added": "Member(s) added.",
   "members_management": "Members management",
   "members_management": "Members management",
@@ -1639,6 +1647,7 @@
   "owner": "Owner",
   "owner": "Owner",
   "page_current": "Page __page__, Current Page",
   "page_current": "Page __page__, Current Page",
   "page_not_found": "Page Not Found",
   "page_not_found": "Page Not Found",
+  "page_x_of_n": "Page __x__ of __n__",
   "pagination_navigation": "Pagination Navigation",
   "pagination_navigation": "Pagination Navigation",
   "papers": "Papers",
   "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__.",
   "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_tag": "Select tag __tagName__",
   "select_tax_id_type": "Select tax ID type",
   "select_tax_id_type": "Select tax ID type",
   "select_user": "Select user",
   "select_user": "Select user",
+  "select_user_role": "Select user role",
   "selected": "Selected",
   "selected": "Selected",
   "selected_by_overleaf_staff": "Selected by Overleaf staff",
   "selected_by_overleaf_staff": "Selected by Overleaf staff",
   "selection_deleted": "Selection deleted",
   "selection_deleted": "Selection deleted",

+ 385 - 0
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(
+      <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')
+    })
+  })
+})

+ 2 - 0
services/web/types/group-management/user.ts

@@ -19,4 +19,6 @@ export type User = {
   last_active_at: Date
   last_active_at: Date
   enrollment?: UserEnrollment
   enrollment?: UserEnrollment
   isEntityAdmin?: boolean
   isEntityAdmin?: boolean
+  isEntityManager?: boolean
+  isEntityMember?: boolean
 }
 }