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() {