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 @@
+
+
+
+
+ Download CSV of All Users
+
+
+
+ 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()}"`
+ );
+ });
+ });
+});