diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index cf1545be..81d7533b 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -1,5 +1,64 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`#user.activate should activate a suspended user 1`] = ` +Object { + "data": Object { + "avatarUrl": "http://example.com/avatar.png", + "email": "user1@example.com", + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": false, + "isSuspended": false, + "name": "User 1", + "username": "user1", + }, + "ok": true, + "status": 200, +} +`; + +exports[`#user.activate should require admin 1`] = ` +Object { + "error": "admin_required", + "message": "An admin role is required to access this resource", + "ok": false, + "status": 403, +} +`; + +exports[`#user.demote should demote an admin 1`] = ` +Object { + "data": Object { + "avatarUrl": "http://example.com/avatar.png", + "email": "user1@example.com", + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": false, + "isSuspended": false, + "name": "User 1", + "username": "user1", + }, + "ok": true, + "status": 200, +} +`; + +exports[`#user.demote should require admin 1`] = ` +Object { + "error": "admin_required", + "message": "An admin role is required to access this resource", + "ok": false, + "status": 403, +} +`; + +exports[`#user.demote shouldn't demote admins if only one available 1`] = ` +Object { + "error": "validation_error", + "message": "At least one admin is required", + "ok": false, + "status": 400, +} +`; + exports[`#user.info should require authentication 1`] = ` Object { "error": "authentication_required", @@ -22,6 +81,65 @@ Object { } `; +exports[`#user.promote should promote a new admin 1`] = ` +Object { + "data": Object { + "avatarUrl": "http://example.com/avatar.png", + "email": "user1@example.com", + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": true, + "isSuspended": false, + "name": "User 1", + "username": "user1", + }, + "ok": true, + "status": 200, +} +`; + +exports[`#user.promote should require admin 1`] = ` +Object { + "error": "admin_required", + "message": "An admin role is required to access this resource", + "ok": false, + "status": 403, +} +`; + +exports[`#user.suspend should require admin 1`] = ` +Object { + "error": "admin_required", + "message": "An admin role is required to access this resource", + "ok": false, + "status": 403, +} +`; + +exports[`#user.suspend should suspend an user 1`] = ` +Object { + "data": Object { + "avatarUrl": "http://example.com/avatar.png", + "email": "user1@example.com", + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": false, + "isSuspended": true, + "name": "User 1", + "username": "user1", + }, + "ok": true, + "status": 200, +} +`; + +exports[`#user.suspend shouldn't allow suspending the user themselves 1`] = ` +Object { + "error": "validation_error", + "message": "Unable to suspend the current user", + "ok": false, + "status": 400, +} +`; + exports[`#user.update should require authentication 1`] = ` Object { "error": "authentication_required", diff --git a/server/api/team.js b/server/api/team.js index a812de8d..d51cba7b 100644 --- a/server/api/team.js +++ b/server/api/team.js @@ -1,14 +1,11 @@ // @flow import Router from 'koa-router'; -import { ValidationError } from '../errors'; -import { Team, User } from '../models'; +import { User } from '../models'; import auth from './middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentUser } from '../presenters'; -import policy from '../policies'; -const { authorize } = policy; const router = new Router(); router.post('team.users', auth(), pagination(), async ctx => { @@ -31,41 +28,4 @@ router.post('team.users', auth(), pagination(), async ctx => { }; }); -router.post('team.addAdmin', auth(), async ctx => { - const userId = ctx.body.user; - const teamId = ctx.state.user.teamId; - ctx.assertPresent(userId, 'id is required'); - - const user = await User.findById(userId); - authorize(ctx.state.user, 'promote', user); - - const team = await Team.findById(teamId); - await team.addAdmin(user); - - ctx.body = { - data: presentUser(ctx, user, { includeDetails: true }), - }; -}); - -router.post('team.removeAdmin', auth(), async ctx => { - const userId = ctx.body.user; - const teamId = ctx.state.user.teamId; - ctx.assertPresent(userId, 'id is required'); - - const user = await User.findById(userId); - authorize(ctx.state.user, 'demote', user); - - const team = await Team.findById(teamId); - - try { - await team.removeAdmin(user); - } catch (err) { - throw new ValidationError(err.message); - } - - ctx.body = { - data: presentUser(ctx, user, { includeDetails: true }), - }; -}); - export default router; diff --git a/server/api/team.test.js b/server/api/team.test.js index 36aa3765..19fb0b0b 100644 --- a/server/api/team.test.js +++ b/server/api/team.test.js @@ -34,72 +34,3 @@ describe('#team.users', async () => { expect(body).toMatchSnapshot(); }); }); - -describe('#team.addAdmin', async () => { - it('should promote a new admin', async () => { - const { admin, user } = await seed(); - - const res = await server.post('/api/team.addAdmin', { - body: { token: admin.getJwtToken(), user: user.id }, - }); - const body = await res.json(); - - expect(res.status).toEqual(200); - expect(body).toMatchSnapshot(); - }); - - it('should require admin', async () => { - const { user } = await seed(); - const res = await server.post('/api/team.addAdmin', { - body: { token: user.getJwtToken(), user: user.id }, - }); - const body = await res.json(); - - expect(res.status).toEqual(403); - expect(body).toMatchSnapshot(); - }); -}); - -describe('#team.removeAdmin', async () => { - it('should demote an admin', async () => { - const { admin, user } = await seed(); - await user.update({ isAdmin: true }); // Make another admin - - const res = await server.post('/api/team.removeAdmin', { - body: { - token: admin.getJwtToken(), - user: user.id, - }, - }); - const body = await res.json(); - - expect(res.status).toEqual(200); - expect(body).toMatchSnapshot(); - }); - - it("shouldn't demote admins if only one available ", async () => { - const { admin } = await seed(); - - const res = await server.post('/api/team.removeAdmin', { - body: { - token: admin.getJwtToken(), - user: admin.id, - }, - }); - const body = await res.json(); - - expect(res.status).toEqual(400); - expect(body).toMatchSnapshot(); - }); - - it('should require admin', async () => { - const { user } = await seed(); - const res = await server.post('/api/team.addAdmin', { - body: { token: user.getJwtToken(), user: user.id }, - }); - const body = await res.json(); - - expect(res.status).toEqual(403); - expect(body).toMatchSnapshot(); - }); -}); diff --git a/server/api/user.js b/server/api/user.js index 252bc9e9..97c09a44 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -2,10 +2,13 @@ import uuid from 'uuid'; import Router from 'koa-router'; import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3'; -import { Event } from '../models'; +import { ValidationError } from '../errors'; +import { Event, User, Team } from '../models'; import auth from './middlewares/authentication'; import { presentUser } from '../presenters'; +import policy from '../policies'; +const { authorize } = policy; const router = new Router(); router.post('user.info', auth(), async ctx => { @@ -75,4 +78,91 @@ router.post('user.s3Upload', auth(), async ctx => { }; }); +// Admin specific + +router.post('user.promote', auth(), async ctx => { + const userId = ctx.body.id; + const teamId = ctx.state.user.teamId; + ctx.assertPresent(userId, 'id is required'); + + const user = await User.findById(userId); + authorize(ctx.state.user, 'promote', user); + + const team = await Team.findById(teamId); + await team.addAdmin(user); + + ctx.body = { + data: presentUser(ctx, user, { includeDetails: true }), + }; +}); + +router.post('user.demote', auth(), async ctx => { + const userId = ctx.body.id; + const teamId = ctx.state.user.teamId; + ctx.assertPresent(userId, 'id is required'); + + const user = await User.findById(userId); + authorize(ctx.state.user, 'demote', user); + + const team = await Team.findById(teamId); + try { + await team.removeAdmin(user); + } catch (err) { + throw new ValidationError(err.message); + } + + ctx.body = { + data: presentUser(ctx, user, { includeDetails: true }), + }; +}); + +/** + * Suspend user + * + * Admin can suspend users to reduce the number of accounts on their billing plan + */ +router.post('user.suspend', auth(), async ctx => { + const admin = ctx.state.user; + const userId = ctx.body.id; + const teamId = ctx.state.user.teamId; + ctx.assertPresent(userId, 'user is required'); + + const user = await User.findById(userId); + authorize(ctx.state.user, 'suspend', user); + + const team = await Team.findById(teamId); + try { + await team.suspendUser(user, admin); + } catch (err) { + throw new ValidationError(err.message); + } + + ctx.body = { + data: presentUser(ctx, user, { includeDetails: true }), + }; +}); + +/** + * Activate user + * + * Admin can activate users to let them access resources. These users will also + * account towards the billing plan limits. + */ +router.post('user.activate', auth(), async ctx => { + const admin = ctx.state.user; + const userId = ctx.body.id; + const teamId = ctx.state.user.teamId; + ctx.assertPresent(userId, 'user is required'); + + const user = await User.findById(userId); + authorize(ctx.state.user, 'activate', user); + + const team = await Team.findById(teamId); + await team.activateUser(user, admin); + + ctx.body = { + data: presentUser(ctx, user, { includeDetails: true }), + }; +}); + export default router; diff --git a/server/api/user.test.js b/server/api/user.test.js index e3bc2644..941b3e0e 100644 --- a/server/api/user.test.js +++ b/server/api/user.test.js @@ -66,3 +66,148 @@ describe('#user.update', async () => { expect(body).toMatchSnapshot(); }); }); + +describe('#user.promote', async () => { + it('should promote a new admin', async () => { + const { admin, user } = await seed(); + + const res = await server.post('/api/user.promote', { + body: { token: admin.getJwtToken(), user: user.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const { user } = await seed(); + const res = await server.post('/api/user.promote', { + body: { token: user.getJwtToken(), user: user.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#user.demote', async () => { + it('should demote an admin', async () => { + const { admin, user } = await seed(); + await user.update({ isAdmin: true }); // Make another admin + + const res = await server.post('/api/user.demote', { + body: { + token: admin.getJwtToken(), + user: user.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body).toMatchSnapshot(); + }); + + it("shouldn't demote admins if only one available ", async () => { + const { admin } = await seed(); + + const res = await server.post('/api/user.demote', { + body: { + token: admin.getJwtToken(), + user: admin.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const { user } = await seed(); + const res = await server.post('/api/user.promote', { + body: { token: user.getJwtToken(), user: user.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#user.suspend', async () => { + it('should suspend an user', async () => { + const { admin, user } = await seed(); + + const res = await server.post('/api/user.suspend', { + body: { + token: admin.getJwtToken(), + user: user.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body).toMatchSnapshot(); + }); + + it("shouldn't allow suspending the user themselves", async () => { + const { admin } = await seed(); + + const res = await server.post('/api/user.suspend', { + body: { + token: admin.getJwtToken(), + user: admin.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const { user } = await seed(); + const res = await server.post('/api/user.suspend', { + body: { token: user.getJwtToken(), user: user.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#user.activate', async () => { + it('should activate a suspended user', async () => { + const { admin, user } = await seed(); + await user.update({ + suspendedById: admin.id, + suspendedAt: new Date(), + }); + + expect(user.isSuspended).toBe(true); + const res = await server.post('/api/user.activate', { + body: { + token: admin.getJwtToken(), + user: user.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const { user } = await seed(); + const res = await server.post('/api/user.activate', { + body: { token: user.getJwtToken(), user: user.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); +}); diff --git a/server/migrations/20180303193036-suspended-users.js b/server/migrations/20180303193036-suspended-users.js new file mode 100644 index 00000000..e49ce6fa --- /dev/null +++ b/server/migrations/20180303193036-suspended-users.js @@ -0,0 +1,18 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('users', 'suspendedById', { + type: Sequelize.UUID, + }); + await queryInterface.addColumn('users', 'suspendedAt', { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('users', 'suspendedById'); + await queryInterface.removeColumn('users', 'suspendedAt'); + } +}; + + diff --git a/server/models/Team.js b/server/models/Team.js index 80dd76f1..87b1ae30 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -63,4 +63,20 @@ Team.prototype.removeAdmin = async function(user: User) { } }; +Team.prototype.suspendUser = async function(user: User, admin: User) { + if (user.id === admin.id) + throw new Error('Unable to suspend the current user'); + return user.update({ + suspendedById: admin.id, + suspendedAt: new Date(), + }); +}; + +Team.prototype.activateUser = async function(user: User, admin: User) { + return user.update({ + suspendedById: null, + suspendedAt: null, + }); +}; + export default Team; diff --git a/server/models/User.js b/server/models/User.js index 8a2f69be..93e44906 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -28,8 +28,14 @@ const User = sequelize.define( slackId: { type: DataTypes.STRING, allowNull: true, unique: true }, slackData: DataTypes.JSONB, jwtSecret: encryptedFields.vault('jwtSecret'), + suspendedAt: DataTypes.DATE, }, { + getterMethods: { + isSuspended() { + return !!this.suspendedAt; + }, + }, indexes: [ { fields: ['email'], @@ -43,6 +49,10 @@ User.associate = models => { User.hasMany(models.ApiKey, { as: 'apiKeys' }); User.hasMany(models.Document, { as: 'documents' }); User.hasMany(models.View, { as: 'views' }); + User.belongsTo(models.User, { + as: 'suspendedBy', + foreignKey: 'suspendedById', + }); }; // Instance methods diff --git a/server/pages/Api.js b/server/pages/Api.js index 43c09878..88f29c2e 100644 --- a/server/pages/Api.js +++ b/server/pages/Api.js @@ -528,32 +528,24 @@ export default function Pricing() { - + Promote a user to be a team admin. This endpoint is only available for admin users. - + - + Demote existing team admin if there are more than one as one admin is always required. This endpoint is only available for admin users. - + diff --git a/server/policies/user.js b/server/policies/user.js index 4e8336b2..22c4858f 100644 --- a/server/policies/user.js +++ b/server/policies/user.js @@ -19,8 +19,13 @@ allow(User, ['update', 'delete'], User, (actor, user) => { throw new AdminRequiredError(); }); -allow(User, ['promote', 'demote'], User, (actor, user) => { - if (!user || user.teamId !== actor.teamId) return false; - if (actor.isAdmin) return true; - throw new AdminRequiredError(); -}); +allow( + User, + ['promote', 'demote', 'activate', 'suspend'], + User, + (actor, user) => { + if (!user || user.teamId !== actor.teamId) return false; + if (actor.isAdmin) return true; + throw new AdminRequiredError(); + } +); diff --git a/server/presenters/user.js b/server/presenters/user.js index 99c6b17d..6dbdeaea 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -30,6 +30,7 @@ export default ( if (options.includeDetails) { userData.isAdmin = user.isAdmin; + userData.isSuspended = user.isSuspended; userData.email = user.email; }