diff --git a/admin-client/src/Router.svelte b/admin-client/src/Router.svelte index eb3287ada..496eb6767 100644 --- a/admin-client/src/Router.svelte +++ b/admin-client/src/Router.svelte @@ -66,7 +66,9 @@ page('/organizations/new', queryString, render(Pages.Organization.New)); page('/organizations/:id', queryString, render(Pages.Organization.Show)); page('/organizations/:id/edit', queryString, render(Pages.Organization.Edit)); - page('/organizations-report', queryString, render(Pages.Organization.Report)); + 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/helpers/downloadCSV.js b/admin-client/src/helpers/downloadCSV.js new file mode 100644 index 000000000..efae06f11 --- /dev/null +++ b/admin-client/src/helpers/downloadCSV.js @@ -0,0 +1,12 @@ +/* eslint-disable import/prefer-default-export */ +export const downloadCSV = async (fetchCSV, filename) => { + const csv = await fetchCSV(); + const blob = new Blob([csv], { type: 'application/octet-stream' }); + const aElement = document.createElement('a'); + aElement.setAttribute('download', filename); + const href = URL.createObjectURL(blob); + aElement.href = href; + aElement.setAttribute('target', '_blank'); + aElement.click(); + URL.revokeObjectURL(href); +}; diff --git a/admin-client/src/lib/api.js b/admin-client/src/lib/api.js index 476a46a74..02338bbd9 100644 --- a/admin-client/src/lib/api.js +++ b/admin-client/src/lib/api.js @@ -203,11 +203,19 @@ async function fetchOrganizations(query = {}) { } async function fetchOrganizationsReport(query = {}) { - return get('/organizations-report', query).catch(() => []); + return get('/reports/organizations', query).catch(() => []); } async function fetchOrganizationsReportCSV() { - return getAttachedFile('/organizations-report.csv').catch(() => []); + return getAttachedFile('/reports/organizations.csv').catch(() => []); +} + +async function fetchPublishedSitesReport(query = {}) { + return get('/reports/published-sites', query).catch(() => []); +} + +async function fetchPublishedSitesReportCSV() { + return getAttachedFile('/reports/published-sites.csv').catch(() => []); } async function updateOrganization(id, params) { @@ -270,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); } @@ -309,6 +325,8 @@ export { fetchOrganizations, fetchOrganizationsReport, fetchOrganizationsReportCSV, + fetchPublishedSitesReport, + fetchPublishedSitesReportCSV, updateOrganization, deactivateOrganization, activateOrganization, @@ -323,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 f16cf852a..138d6d48d 100644 --- a/admin-client/src/pages/Reports.svelte +++ b/admin-client/src/pages/Reports.svelte @@ -4,6 +4,8 @@ Reports \ No newline at end of file diff --git a/admin-client/src/pages/organization/OrgsReport.svelte b/admin-client/src/pages/organization/OrgsReport.svelte new file mode 100644 index 000000000..dbc3af008 --- /dev/null +++ b/admin-client/src/pages/organization/OrgsReport.svelte @@ -0,0 +1,25 @@ + + + +
+ +
+ + + Organization + Agency + + + + {org.name} + + + {org.agency} + + + +
\ No newline at end of file diff --git a/admin-client/src/pages/organization/Report.svelte b/admin-client/src/pages/organization/PublishedSitesReport.svelte similarity index 63% rename from admin-client/src/pages/organization/Report.svelte rename to admin-client/src/pages/organization/PublishedSitesReport.svelte index 336959d44..0253b9e42 100644 --- a/admin-client/src/pages/organization/Report.svelte +++ b/admin-client/src/pages/organization/PublishedSitesReport.svelte @@ -1,19 +1,8 @@ - +
- +
diff --git a/admin-client/src/pages/organization/index.js b/admin-client/src/pages/organization/index.js index 6e1e294fb..705dec19c 100644 --- a/admin-client/src/pages/organization/index.js +++ b/admin-client/src/pages/organization/index.js @@ -2,4 +2,5 @@ export { default as Edit } from './Edit.svelte'; export { default as New } from './New.svelte'; export { default as Index } from './Index.svelte'; export { default as Show } from './Show.svelte'; -export { default as Report } from './Report.svelte'; +export { default as OrgsReport } from './OrgsReport.svelte'; +export { default as PublishedSitesReport } from './PublishedSitesReport.svelte'; diff --git a/admin-client/src/pages/user/UsersReport.svelte b/admin-client/src/pages/user/UsersReport.svelte new file mode 100644 index 000000000..a8e5a711e --- /dev/null +++ b/admin-client/src/pages/user/UsersReport.svelte @@ -0,0 +1,62 @@ + + + +
+ +
+ + + 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/domain.js b/api/admin/controllers/domain.js index 2e6b0cebc..6bc314671 100644 --- a/api/admin/controllers/domain.js +++ b/api/admin/controllers/domain.js @@ -116,7 +116,7 @@ module.exports = wrapHandlers({ const parser = new json2csv.Parser({ fields }); const csv = parser.parse(domains); - res.attachment('organizations-report.csv'); + res.attachment('published-sites.csv'); return res.send(csv); }, diff --git a/api/admin/controllers/organization.js b/api/admin/controllers/organization.js index e95fc2701..75c239791 100644 --- a/api/admin/controllers/organization.js +++ b/api/admin/controllers/organization.js @@ -1,3 +1,4 @@ +const json2csv = require('@json2csv/plainjs'); const { serialize, serializeMany } = require('../../serializers/organization'); const { paginate, wrapHandlers } = require('../../utils'); const { Organization, Event } = require('../../models'); @@ -41,6 +42,41 @@ module.exports = wrapHandlers({ return res.json(json); }, + async listOrdered(req, res) { + const { limit, page } = req.query; + + const scopes = ['byName']; + + const pagination = await paginate(Organization.scope(scopes), serializeMany, { limit, page }); + + const json = { + meta: {}, + ...pagination, + }; + + return res.json(json); + }, + + async listOrderedCSV(req, res) { + const orgs = await Organization.scope('byName').findAll(); + + const fields = [ + { + label: 'Organization', + value: 'name', + }, + { + label: 'Agency', + value: 'agency', + }, + ]; + + const parser = new json2csv.Parser({ fields }); + const csv = parser.parse(orgs); + res.attachment('organizations.csv'); + return res.send(csv); + }, + async findById(req, res) { const { params: { id }, 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 a6759a249..d5d83404e 100644 --- a/api/admin/routers/api.js +++ b/api/admin/routers/api.js @@ -35,8 +35,12 @@ apiRouter.put('/organizations/:id', AdminControllers.Organization.update); apiRouter.post('/organizations/:id/deactivate', authorize(['pages.admin']), AdminControllers.Organization.deactivate); apiRouter.post('/organizations/:id/activate', AdminControllers.Organization.activate); apiRouter.delete('/organization/:org_id/user/:user_id', authorize(['pages.admin']), AdminControllers.OrganizationRole.destroy); -apiRouter.get('/organizations-report', AdminControllers.Domain.listPublished); -apiRouter.get('/organizations-report.csv', AdminControllers.Domain.listPublishedCSV); +apiRouter.get('/reports/organizations', AdminControllers.Organization.listOrdered); +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/api/models/organization.js b/api/models/organization.js index f2eec6c01..226fb0f41 100644 --- a/api/models/organization.js +++ b/api/models/organization.js @@ -76,6 +76,12 @@ const associate = ({ }], }], })); + + Organization.addScope('byName', { + order: [ + ['name', 'ASC'], + ], + }); }; module.exports = (sequelize, DataTypes) => { diff --git a/test/api/admin/requests/domain.test.js b/test/api/admin/requests/domain.test.js index 8dc022bbb..2b76853d0 100644 --- a/test/api/admin/requests/domain.test.js +++ b/test/api/admin/requests/domain.test.js @@ -51,10 +51,10 @@ describe('Admin - Domains API', () => { }); }); - describe('GET /admin/organizations-report', () => { + describe('GET /admin/reports/published-sites', () => { it('should require admin authentication', async () => { const response = await request(app) - ['get']('/organizations-report') + ['get']('/reports/published-sites') .expect(401); expect(response.body.message).to.equal('Unauthorized'); }); @@ -81,7 +81,7 @@ describe('Admin - Domains API', () => { const cookie = await authenticatedSession(user, sessionConfig); const { body } = await request(app) - .get('/organizations-report') + .get('/reports/published-sites') .set('Cookie', cookie) .set('Origin', config.app.adminHostname) .expect(200); @@ -97,10 +97,10 @@ describe('Admin - Domains API', () => { }); }); - describe('GET /admin/organizations-report.csv', () => { + describe('GET /admin/reports/published-sites.csv', () => { it('should require admin authentication', async () => { const response = await request(app) - ['get']('/organizations-report.csv') + ['get']('/reports/published-sites.csv') .expect(401); expect(response.body.message).to.equal('Unauthorized'); }); @@ -128,7 +128,7 @@ describe('Admin - Domains API', () => { const cookie = await authenticatedSession(user, sessionConfig); const response = await request(app) - .get('/organizations-report.csv') + .get('/reports/published-sites.csv') .set('Cookie', cookie) .set('Origin', config.app.adminHostname) .expect(200); @@ -136,7 +136,7 @@ describe('Admin - Domains API', () => { 'text/csv; charset=utf-8' ); expect(response.headers['content-disposition']).to.equal( - 'attachment; filename="organizations-report.csv"' + 'attachment; filename="published-sites.csv"' ); [header, ...data] = response.text.split(/\n/); expect(header).to.equal( diff --git a/test/api/admin/requests/organization.test.js b/test/api/admin/requests/organization.test.js new file mode 100644 index 000000000..d5185f3ed --- /dev/null +++ b/test/api/admin/requests/organization.test.js @@ -0,0 +1,87 @@ +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, + User, +} = require('../../../../api/models'); + +describe('Admin - Organizations API', () => { + afterEach(() => + Promise.all([ + User.truncate(), + Organization.truncate(), + ]) + ); + + describe('GET /admin/reports/organizations', () => { + it('should require admin authentication', async () => { + const response = await request(app) + ['get']('/reports/organizations') + .expect(401); + expect(response.body.message).to.equal('Unauthorized'); + }); + + it('returns all organizations', async () => { + const user = await factory.user(); + + const org1 = await factory.organization.create(); + const org2 = await factory.organization.create(); + + const cookie = await authenticatedSession(user, sessionConfig); + const { body } = await request(app) + .get('/reports/organizations') + .set('Cookie', cookie) + .set('Origin', config.app.adminHostname) + .expect(200); + + expect(body.data.length).to.equal(2); + expect(body.data[0]['id']).to.equal(org1.id); + expect(body.data[1]['id']).to.equal(org2.id); + }); + }); + + describe('GET /admin/reports/organizations.csv', () => { + it('should require admin authentication', async () => { + const response = await request(app) + ['get']('/reports/organizations.csv') + .expect(401); + expect(response.body.message).to.equal('Unauthorized'); + }); + + it('returns all organizations', async () => { + const user = await factory.user(); + + const org1 = await factory.organization.create({agency: 'Agency 1'}); + const org2 = await factory.organization.create({agency: 'Agency 2'}); + + const cookie = await authenticatedSession(user, sessionConfig); + const response = await request(app) + .get('/reports/organizations.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="organizations.csv"' + ); + [header, ...data] = response.text.split(/\n/); + expect(header).to.equal( + '"Organization","Agency"' + ); + expect(data.length).to.equal(2); + expect(data[0]).to.equal( + `"${org1.name}","${org1.agency}"` + ); + expect(data[1]).to.equal( + `"${org2.name}","${org2.agency}"` + ); + }); + }); +}); 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()}"` + ); + }); + }); +}); diff --git a/test/api/unit/models/organization.test.js b/test/api/unit/models/organization.test.js index eee137b92..1f618f372 100644 --- a/test/api/unit/models/organization.test.js +++ b/test/api/unit/models/organization.test.js @@ -205,6 +205,20 @@ describe('Organization model', () => { }); }); + describe('.byName()', () => { + it('returns organizations ordered by name', async () => { + const orgB = Organization.create({ name: 'Org B' }); + const orgA = Organization.create({ name: 'Org A' }); + const orgC = Organization.create({ name: 'Org C' }); + const result = await Organization.scope('byName').findAll(); + expect(result.map((org) => org.id)).to.include.ordered.members([ + orgA.id, + orgB.id, + orgC.id, + ]); + }); + }); + describe('forUser', () => { it('returns all orgs for the user and includes the `OrganizationRole` and `User`.', async () => { const [user, org1, org2] = await Promise.all([