diff --git a/admin-client/src/Router.svelte b/admin-client/src/Router.svelte index 8078f83bb..496eb6767 100644 --- a/admin-client/src/Router.svelte +++ b/admin-client/src/Router.svelte @@ -68,6 +68,7 @@ page('/organizations/:id/edit', queryString, render(Pages.Organization.Edit)); page('/reports/organizations', queryString, render(Pages.Organization.OrgsReport)); page('/reports/published-sites', queryString, render(Pages.Organization.PublishedSitesReport)); + page('/reports/users', queryString, render(Pages.User.UsersReport)); page('/reports', queryString, render(Pages.Reports)); page('/tasks', queryString, render(Pages.Tasks)); page('*', render(Pages.NotFound)); diff --git a/admin-client/src/lib/api.js b/admin-client/src/lib/api.js index da17beb85..02338bbd9 100644 --- a/admin-client/src/lib/api.js +++ b/admin-client/src/lib/api.js @@ -278,6 +278,14 @@ async function fetchUsers(query = {}) { return get('/users', query).catch(() => []); } +async function fetchUsersReport(query = {}) { + return get('/reports/users', query).catch(() => []); +} + +async function fetchUsersReportCSV() { + return getAttachedFile('/reports/users.csv').catch(() => []); +} + async function inviteUser(params) { return post('/users/invite', params); } @@ -333,6 +341,8 @@ export { fetchUserEnvironmentVariables, fetchUser, fetchUsers, + fetchUsersReport, + fetchUsersReportCSV, inviteUser, resendInvite, logout, diff --git a/admin-client/src/pages/Reports.svelte b/admin-client/src/pages/Reports.svelte index ba22c497d..138d6d48d 100644 --- a/admin-client/src/pages/Reports.svelte +++ b/admin-client/src/pages/Reports.svelte @@ -6,5 +6,6 @@ \ No newline at end of file diff --git a/admin-client/src/pages/user/UsersReport.svelte b/admin-client/src/pages/user/UsersReport.svelte new file mode 100644 index 000000000..b91737841 --- /dev/null +++ b/admin-client/src/pages/user/UsersReport.svelte @@ -0,0 +1,73 @@ + + + +
+ +
+ + + ID + Github Email + UAA Email + Organizations + Details + Created + Signed In + + + + {user.id} + + + {#if user.email } + {user.email} + {/if} + + + {#if user.UAAIdentity } + {user.UAAIdentity.email} + {/if} + + + {#if user.OrganizationRoles.length > 0} + { + user.OrganizationRoles.map((orgRole) => `${orgRole.Organization.name}`).join(', ') + } + {/if} + + + {#if user.OrganizationRoles.length > 0} + { + user.OrganizationRoles.map((orgRole) => `${orgRole.Organization.name}: ${orgRole.Role.name}`).join(', ') + } + {/if} + + + { formatDistanceStrict(new Date(user.createdAt), new Date(), { addSuffix: true, roundingMethod: 'floor' }) } + + + {#if user.signedInAt } + { formatDistanceStrict(new Date(user.signedInAt), new Date(), { addSuffix: true, roundingMethod: 'floor' }) } + {:else } + never + {/if } + + + +
\ No newline at end of file diff --git a/admin-client/src/pages/user/index.js b/admin-client/src/pages/user/index.js index 4ede346be..4c40702b0 100644 --- a/admin-client/src/pages/user/index.js +++ b/admin-client/src/pages/user/index.js @@ -1,3 +1,4 @@ export { default as Show } from './Show.svelte'; export { default as Index } from './Index.svelte'; export { default as Invite } from './Invite.svelte'; +export { default as UsersReport } from './UsersReport.svelte'; diff --git a/api/admin/controllers/user.js b/api/admin/controllers/user.js index 910e40e33..02a9bb7a8 100644 --- a/api/admin/controllers/user.js +++ b/api/admin/controllers/user.js @@ -1,3 +1,4 @@ +const json2csv = require('@json2csv/plainjs'); const { Organization, Site, User, Event, } = require('../../models'); @@ -52,6 +53,59 @@ module.exports = wrapHandlers({ return res.json(json); }, + async listForUsersReport(req, res) { + const { limit, page } = req.query; + + const serialize = users => userSerializer.serializeMany(users, true); + + const scopes = ['withUAAIdentity', 'withOrganizationRoles']; + + const pagination = await paginate(User.scope(scopes), serialize, { limit, page }); + + const json = { + meta: {}, + ...pagination, + }; + + return res.json(json); + }, + + async listForUsersReportCSV(req, res) { + const users = await User.scope(['withUAAIdentity', 'withOrganizationRoles']).findAll(); + + const fields = [ + { + label: 'ID', + value: 'id', + }, + { + label: 'Email', + value: 'UAAIdentity.email', + }, + { + label: 'Organizations', + value: (user => user.OrganizationRoles.map(orgRole => `${orgRole.Organization.name}`).join('|')), + }, + { + label: 'Details', + value: (user => user.OrganizationRoles.map(orgRole => `${orgRole.Organization.name}: ${orgRole.Role.name}`).join(', ')), + }, + { + label: 'Created', + value: 'createdAt', + }, + { + label: 'Last Signed In', + value: 'signedInAt', + }, + ]; + + const parser = new json2csv.Parser({ fields }); + const csv = parser.parse(users); + res.attachment('users.csv'); + return res.send(csv); + }, + async findById(req, res) { const { params: { id }, diff --git a/api/admin/routers/api.js b/api/admin/routers/api.js index b9a5f2627..d5d83404e 100644 --- a/api/admin/routers/api.js +++ b/api/admin/routers/api.js @@ -39,6 +39,8 @@ apiRouter.get('/reports/organizations', AdminControllers.Organization.listOrdere apiRouter.get('/reports/organizations.csv', AdminControllers.Organization.listOrderedCSV); apiRouter.get('/reports/published-sites', AdminControllers.Domain.listPublished); apiRouter.get('/reports/published-sites.csv', AdminControllers.Domain.listPublishedCSV); +apiRouter.get('/reports/users', AdminControllers.User.listForUsersReport); +apiRouter.get('/reports/users.csv', AdminControllers.User.listForUsersReportCSV); apiRouter.put('/organization-role', AdminControllers.OrganizationRole.update); apiRouter.get('/roles', AdminControllers.Role.list); apiRouter.get('/sites', AdminControllers.Site.list); diff --git a/test/api/admin/requests/user.test.js b/test/api/admin/requests/user.test.js new file mode 100644 index 000000000..0a6b634e7 --- /dev/null +++ b/test/api/admin/requests/user.test.js @@ -0,0 +1,112 @@ +const request = require('supertest'); +const { expect } = require('chai'); +const app = require('../../../../api/admin'); +const { authenticatedSession } = require('../../support/session'); +const sessionConfig = require('../../../../api/admin/sessionConfig'); +const factory = require('../../support/factory'); +const config = require('../../../../config'); +const { + Organization, OrganizationRole, Role, User, UAAIdentity, +} = require('../../../../api/models'); +const { createUAAIdentity } = require('../../support/factory/uaa-identity'); + +describe('Admin - Organizations API', () => { + let userRole; + let managerRole; + + before(async () => { + [userRole, managerRole] = await Promise.all([ + Role.findOne({ where: { name: 'user' } }), + Role.findOne({ where: { name: 'manager' } }), + ]); + }); + + afterEach(() => + Promise.all([ + Organization.truncate({ force: true, cascade: true }), + OrganizationRole.truncate({ force: true, cascade: true }), + User.truncate({ force: true, cascade: true }), + ]) + ); + + describe('GET /admin/reports/users', () => { + it('should require admin authentication', async () => { + const response = await request(app) + ['get']('/reports/users') + .expect(401); + expect(response.body.message).to.equal('Unauthorized'); + }); + + it('returns all users', async () => { + const user1 = await factory.user(); + const user2 = await factory.user(); + + const org1 = await factory.organization.create(); + const org2 = await factory.organization.create(); + + org1.addUser(user1, { through: { roleId: managerRole.id } }); + org1.addUser(user2, { through: { roleId: userRole.id } }); + org2.addUser(user1, { through: { roleId: userRole.id } }); + + const cookie = await authenticatedSession(user1, sessionConfig); + const { body } = await request(app) + .get('/reports/users') + .set('Cookie', cookie) + .set('Origin', config.app.adminHostname) + .expect(200); + + expect(body.data.length).to.equal(2); + ids = body.data.map(user => user['id']); + expect(ids).to.include(user1.id); + expect(ids).to.include(user2.id); + }); + }); + + describe('GET /admin/reports/users.csv', () => { + it('should require admin authentication', async () => { + const response = await request(app) + ['get']('/reports/users.csv') + .expect(401); + expect(response.body.message).to.equal('Unauthorized'); + }); + + it('returns all users', async () => { + const user1 = await factory.user(); + createUAAIdentity({uaaId: 'user_id_1', email: 'user1@example.com', userId: user1.id }); + + const user2 = await factory.user(); + createUAAIdentity({uaaId: 'user_id_2', email: 'user2@example.com', userId: user2.id }); + + const org1 = await factory.organization.create(); + const org2 = await factory.organization.create(); + + org1.addUser(user1, { through: { roleId: managerRole.id } }); + org1.addUser(user2, { through: { roleId: userRole.id } }); + org2.addUser(user1, { through: { roleId: userRole.id } }); + + const cookie = await authenticatedSession(user1, sessionConfig); + const response = await request(app) + .get('/reports/users.csv') + .set('Cookie', cookie) + .set('Origin', config.app.adminHostname) + .expect(200); + expect(response.headers['content-type']).to.equal( + 'text/csv; charset=utf-8' + ); + expect(response.headers['content-disposition']).to.equal( + 'attachment; filename="users.csv"' + ); + [header, ...data] = response.text.split(/\n/); + expect(header).to.equal( + '"ID","Email","Organizations","Details","Created","Last Signed In"' + ); + expect(data.length).to.equal(2); + expect(data).to.include( + `${user1.id},"user1@example.com","${org1.name}|${org2.name}","${org1.name}: manager, ${org2.name}: user","${user1.createdAt.toISOString()}","${user1.signedInAt.toISOString()}"` + ); + expect(data).to.include( + `${user2.id},"user2@example.com","${org1.name}","${org1.name}: user","${user2.createdAt.toISOString()}","${user2.signedInAt.toISOString()}"` + ); + }); + }); +});