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;
}