From 1768bef22af889a79971decdbda83674ca75ff7a Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Mon, 14 Jul 2025 09:08:28 -0500 Subject: [PATCH] Merge pull request #26366 from overleaf/jel-group-csv [web] Include in group members CSV if user is managed and/or linked to the group's SSO GitOrigin-RevId: 449974917d98cf121ea46eaa58be4b3666d88268 --- .../UserMembershipController.mjs | 68 +++++++--- .../UserMembershipController.test.mjs | 117 +++++++++++++++++- 2 files changed, 167 insertions(+), 18 deletions(-) diff --git a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs index 4be1221255..1f2516b0f5 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs +++ b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs @@ -13,6 +13,7 @@ import { Parser as CSVParser } from 'json2csv' import { expressify } from '@overleaf/promise-utils' import PlansLocator from '../Subscription/PlansLocator.js' import RecurlyClient from '../Subscription/RecurlyClient.js' +import Modules from '../../infrastructure/Modules.js' async function manageGroupMembers(req, res, next) { const { entity: subscription, entityConfig } = req @@ -121,6 +122,56 @@ async function _renderManagersPage(req, res, next, template) { }) } +async function exportCsv(req, res) { + let ssoEnabled + const { entity, entityConfig } = req + const fields = ['email', 'last_logged_in_at', 'last_active_at'] + + const { managedUsersEnabled } = entity + + let users = await UserMembershipHandler.promises.getUsers( + entity, + entityConfig + ) + + if (entity.ssoConfig) { + const ssoEnabledResult = await Modules.promises.hooks.fire( + 'hasGroupSSOEnabled', + entity + ) + ssoEnabled = ssoEnabledResult?.[0] + } + + if (managedUsersEnabled) { + fields.push('managed') + } + + if (ssoEnabled) { + fields.push('sso') + } + + if (managedUsersEnabled || ssoEnabled) { + users = users.map(user => { + if (managedUsersEnabled) { + user.managed = + user.enrollment?.managedBy?.toString() === entity._id.toString() + } + + if (ssoEnabled) { + user.sso = !!user.enrollment?.sso?.some( + groupLinked => + groupLinked.groupId.toString() === entity._id.toString() + ) + } + return user + }) + } + + const csvParser = new CSVParser({ fields }) + + csvAttachment(res, csvParser.parse(users), 'Group.csv') +} + export default { manageGroupMembers: expressify(manageGroupMembers), manageGroupManagers: expressify(manageGroupManagers), @@ -208,22 +259,7 @@ export default { } ) }, - exportCsv(req, res, next) { - const { entity, entityConfig } = req - const fields = ['email', 'last_logged_in_at', 'last_active_at'] - - UserMembershipHandler.getUsers( - entity, - entityConfig, - function (error, users) { - if (error != null) { - return next(error) - } - const csvParser = new CSVParser({ fields }) - csvAttachment(res, csvParser.parse(users), 'Group.csv') - } - ) - }, + exportCsv: expressify(exportCsv), new(req, res, next) { res.render('user_membership/new', { entityName: req.params.name, diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index de2391275d..1726b2a41b 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs @@ -59,6 +59,48 @@ describe('UserMembershipController', function () { last_logged_in_at: '2020-05-20T10:41:11.407Z', last_active_at: '2021-05-20T10:41:11.407Z', }, + { + _id: 'mock-member-id-3', + email: 'mock-email-3@foo.com', + last_logged_in_at: '2021-08-10T10:41:11.407Z', + last_active_at: '2021-08-20T10:41:11.407Z', + enrollment: { + managedBy: 'some-other-subscription-id', + enrolledAt: '2021-05-20T10:41:11.407Z', + sso: undefined, + }, + }, + { + _id: 'mock-member-id-4', + email: 'mock-email-4@foo.com', + last_logged_in_at: '2021-01-01T10:41:11.407Z', + last_active_at: '2021-01-02T10:41:11.407Z', + enrollment: { + managedBy: 'mock-subscription-id', + enrolledAt: '2021-01-02T10:41:11.407Z', + sso: undefined, + }, + }, + { + _id: 'mock-member-id-5', + email: 'mock-email-5@foo.com', + last_logged_in_at: '2023-01-01T10:41:11.407Z', + last_active_at: '2023-01-02T10:41:11.407Z', + enrollment: { + sso: [{ groupId: ctx.subscription._id }], + }, + }, + { + _id: 'mock-member-id-6', + email: 'mock-email-6@foo.com', + last_logged_in_at: '2024-01-01T10:41:11.407Z', + last_active_at: '2024-01-02T10:41:11.407Z', + enrollment: { + managedBy: 'mock-subscription-id', + enrolledAt: '2024-01-02T10:41:11.407Z', + sso: [{ groupId: ctx.subscription._id }], + }, + }, ] ctx.Settings = { @@ -143,6 +185,17 @@ describe('UserMembershipController', function () { SSOConfig: ctx.SSOConfig, })) + ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub(), + }, + }, + } + vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({ + default: ctx.Modules, + })) + ctx.UserMembershipController = (await import(modulePath)).default }) @@ -377,7 +430,7 @@ describe('UserMembershipController', function () { it('get users', function (ctx) { sinon.assert.calledWithMatch( - ctx.UserMembershipHandler.getUsers, + ctx.UserMembershipHandler.promises.getUsers, ctx.subscription, { modelName: 'Subscription' } ) @@ -398,7 +451,67 @@ describe('UserMembershipController', function () { it('should export the correct csv', function (ctx) { assertCalledWith( ctx.res.send, - '"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"' + '"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z"\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z"\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z"\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z"' + ) + }) + }) + + describe('exportCsv when group is managed', function () { + beforeEach(function (ctx) { + ctx.req.entity = Object.assign( + { managedUsersEnabled: true }, + ctx.subscription + ) + ctx.req.entityConfig = EntityConfigs.groupManagers + ctx.res = new MockResponse() + ctx.UserMembershipController.exportCsv(ctx.req, ctx.res) + }) + + it('should export the correct csv', function (ctx) { + assertCalledWith( + ctx.res.send, + '"email","last_logged_in_at","last_active_at","managed"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true' + ) + }) + }) + + describe('exportCsv when group has SSO', function () { + beforeEach(function (ctx) { + ctx.req.entity = Object.assign( + { ssoConfig: 'sso-config-id' }, + ctx.subscription + ) + ctx.req.entityConfig = EntityConfigs.groupManagers + ctx.Modules.promises.hooks.fire.resolves([true]) + ctx.res = new MockResponse() + ctx.UserMembershipController.exportCsv(ctx.req, ctx.res) + }) + + it('should export the correct csv', function (ctx) { + assertCalledWith( + ctx.res.send, + '"email","last_logged_in_at","last_active_at","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true' + ) + }) + }) + + describe('exportCsv when group has SSO and managed users enabled', function () { + beforeEach(function (ctx) { + ctx.req.entity = Object.assign( + { managedUsersEnabled: true }, + { ssoConfig: 'sso-config-id' }, + ctx.subscription + ) + ctx.req.entityConfig = EntityConfigs.groupManagers + ctx.Modules.promises.hooks.fire.resolves([true]) + ctx.res = new MockResponse() + ctx.UserMembershipController.exportCsv(ctx.req, ctx.res) + }) + + it('should export the correct csv', function (ctx) { + assertCalledWith( + ctx.res.send, + '"email","last_logged_in_at","last_active_at","managed","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false,false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false,false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false,false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true,false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false,true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true,true' ) }) })