diff --git a/package.json b/package.json index d2a4b6ef..8a93a4fc 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "redis-lock": "^0.1.0", "rimraf": "^2.5.4", "safestart": "1.1.0", - "sequelize": "^4.3.1", + "sequelize": "4.28.6", "sequelize-cli": "^2.7.0", "sequelize-encrypted": "0.1.0", "slate": "^0.31.5", diff --git a/server/api/__snapshots__/team.test.js.snap b/server/api/__snapshots__/team.test.js.snap new file mode 100644 index 00000000..07df222e --- /dev/null +++ b/server/api/__snapshots__/team.test.js.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#team.addAdmin should promote a new admin 1`] = ` +Object { + "avatarUrl": "http://example.com/avatar.png", + "email": "user1@example.com", + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": true, + "name": "User 1", + "ok": true, + "status": 200, + "username": "user1", +} +`; + +exports[`#team.addAdmin should require admin 1`] = ` +Object { + "error": "only_available_for_admins", + "message": "Only available for admins", + "ok": false, + "status": 403, +} +`; + +exports[`#team.removeAdmin should demote an admin 1`] = ` +Object { + "avatarUrl": null, + "ok": true, + "status": 200, +} +`; + +exports[`#team.removeAdmin should require admin 1`] = ` +Object { + "error": "only_available_for_admins", + "message": "Only available for admins", + "ok": false, + "status": 403, +} +`; + +exports[`#team.removeAdmin shouldn't demote admins if only one available 1`] = ` +Object { + "error": "at_least_one_admin_is_required", + "message": "At least one admin is required", + "ok": false, + "status": 400, +} +`; + +exports[`#team.users should require admin 1`] = ` +Object { + "error": "only_available_for_admins", + "message": "Only available for admins", + "ok": false, + "status": 403, +} +`; + +exports[`#team.users should return teams paginated user list 1`] = ` +Object { + "data": Array [ + Object { + "avatarUrl": "http://example.com/avatar.png", + "email": "admin@example.com", + "id": "fa952cff-fa64-4d42-a6ea-6955c9689046", + "isAdmin": true, + "name": "Admin User", + "username": "admin", + }, + Object { + "avatarUrl": "http://example.com/avatar.png", + "email": "user1@example.com", + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": false, + "name": "User 1", + "username": "user1", + }, + ], + "ok": true, + "pagination": Object { + "limit": 15, + "nextPath": "/api/team.users?limit=15&offset=15", + "offset": 0, + }, + "status": 200, +} +`; diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index 6ad5d9af..42c340db 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -13,7 +13,6 @@ exports[`#user.info should return known user 1`] = ` Object { "data": Object { "avatarUrl": "http://example.com/avatar.png", - "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "name": "User 1", "username": "user1", diff --git a/server/api/auth.js b/server/api/auth.js index 6916739c..91d965e2 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -13,7 +13,7 @@ router.post('auth.info', auth(), async ctx => { ctx.body = { data: { - user: await presentUser(ctx, user), + user: await presentUser(ctx, user, { includeDetails: true }), team: await presentTeam(ctx, team), }, }; @@ -51,9 +51,14 @@ router.post('auth.slack', async ctx => { name: data.user.name, email: data.user.email, teamId: team.id, + isAdmin: !teamExisted, slackData: data.user, slackAccessToken: data.access_token, }); + + // Set initial avatar + await user.updateAvatar(); + await user.save(); } if (!teamExisted) { @@ -68,10 +73,6 @@ router.post('auth.slack', async ctx => { expires: new Date('2100'), }); - // Update user's avatar - await user.updateAvatar(); - await user.save(); - ctx.body = { data: { user: await presentUser(ctx, user), diff --git a/server/api/hooks.js b/server/api/hooks.js index 25051313..28c07186 100644 --- a/server/api/hooks.js +++ b/server/api/hooks.js @@ -59,6 +59,8 @@ router.post('hooks.slack', async ctx => { }); if (!user) throw httpErrors.BadRequest('Invalid user'); + if (!user.isAdmin) + throw httpErrors.BadRequest('Only admins can add integrations'); const documents = await Document.searchForUser(user, text, { limit: 5, diff --git a/server/api/team.js b/server/api/team.js new file mode 100644 index 00000000..416f0874 --- /dev/null +++ b/server/api/team.js @@ -0,0 +1,70 @@ +// @flow +import Router from 'koa-router'; +import httpErrors from 'http-errors'; + +import User from '../models/User'; +import Team from '../models/Team'; + +import auth from './middlewares/authentication'; +import pagination from './middlewares/pagination'; +import { presentUser } from '../presenters'; + +const router = new Router(); +router.use(auth({ adminOnly: true })); + +router.post('team.users', pagination(), async ctx => { + const user = ctx.state.user; + + const users = await User.findAll({ + where: { + teamId: user.teamId, + }, + order: [['createdAt', 'DESC']], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: users.map(user => presentUser(ctx, user, { includeDetails: true })), + }; +}); + +router.post('team.addAdmin', async ctx => { + const { user } = ctx.body; + const admin = ctx.state.user; + ctx.assertPresent(user, 'id is required'); + + const team = await Team.findById(admin.teamId); + const promotedUser = await User.findOne({ + where: { id: user, teamId: admin.teamId }, + }); + + if (!promotedUser) throw httpErrors.NotFound(); + + await team.addAdmin(promotedUser); + + ctx.body = presentUser(ctx, promotedUser, { includeDetails: true }); +}); + +router.post('team.removeAdmin', async ctx => { + const { user } = ctx.body; + const admin = ctx.state.user; + ctx.assertPresent(user, 'id is required'); + + const team = await Team.findById(admin.teamId); + const demotedUser = await User.findOne({ + where: { id: user, teamId: admin.teamId }, + }); + + if (!demotedUser) throw httpErrors.NotFound(); + + try { + await team.removeAdmin(demotedUser); + ctx.body = presentUser(ctx, user, { includeDetails: true }); + } catch (e) { + throw httpErrors.BadRequest(e.message); + } +}); + +export default router; diff --git a/server/api/team.test.js b/server/api/team.test.js new file mode 100644 index 00000000..ba4ad3be --- /dev/null +++ b/server/api/team.test.js @@ -0,0 +1,108 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from 'fetch-test-server'; + +import app from '..'; + +import { flushdb, seed } from '../test/support'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(server.close); + +describe('#team.users', async () => { + it('should return teams paginated user list', async () => { + const { admin } = await seed(); + + const res = await server.post('/api/team.users', { + body: { token: admin.getJwtToken() }, + }); + 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.users', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + 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() }, + }); + 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() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); +}); diff --git a/server/models/Team.js b/server/models/Team.js index bd486117..80cf8852 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -1,6 +1,7 @@ // @flow -import { DataTypes, sequelize } from '../sequelize'; +import { DataTypes, sequelize, Op } from '../sequelize'; import Collection from './Collection'; +import User from './User'; const Team = sequelize.define( 'team', @@ -41,4 +42,26 @@ Team.prototype.createFirstCollection = async function(userId) { return atlas; }; +Team.prototype.addAdmin = async function(user: User) { + return await user.update({ isAdmin: true }); +}; + +Team.prototype.removeAdmin = async function(user: User) { + const res = await User.findAndCountAll({ + where: { + teamId: user.teamId, + isAdmin: true, + id: { + [Op.ne]: user.id, + }, + }, + limit: 1, + }); + if (res.count >= 1) { + return await user.update({ isAdmin: false }); + } else { + throw new Error('At least one admin is required'); + } +}; + export default Team; diff --git a/server/presenters/__snapshots__/user.test.js.snap b/server/presenters/__snapshots__/user.test.js.snap index 8a5c7ad1..a4e6bd70 100644 --- a/server/presenters/__snapshots__/user.test.js.snap +++ b/server/presenters/__snapshots__/user.test.js.snap @@ -3,7 +3,6 @@ exports[`presents a user 1`] = ` Object { "avatarUrl": "http://example.com/avatar.png", - "email": undefined, "id": "123", "name": "Test User", "username": "testuser", @@ -13,7 +12,6 @@ Object { exports[`presents a user without slack data 1`] = ` Object { "avatarUrl": null, - "email": undefined, "id": "123", "name": "Test User", "username": "testuser", diff --git a/server/presenters/document.js b/server/presenters/document.js index a665b307..4414522f 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -1,5 +1,6 @@ // @flow import _ from 'lodash'; +import { Op } from 'sequelize'; import { User, Document } from '../models'; import presentUser from './user'; import presentCollection from './collection'; @@ -57,7 +58,7 @@ async function present(ctx: Object, document: Document, options: ?Options) { // This could be further optimized by using ctx.cache data.collaborators = await User.findAll({ where: { - id: { $in: _.takeRight(document.collaboratorIds, 10) || [] }, + id: { [Op.in]: _.takeRight(document.collaboratorIds, 10) || [] }, }, }).map(user => presentUser(ctx, user)); diff --git a/server/presenters/user.js b/server/presenters/user.js index 1281c45d..2b6ff1f7 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -1,17 +1,37 @@ // @flow import User from '../models/User'; -function present(ctx: Object, user: User) { +type Options = { + includeDetails?: boolean, +}; + +type UserPresentation = { + id: string, + username: string, + name: string, + avatarUrl: ?string, + email?: string, + isAdmin?: boolean, +}; + +export default ( + ctx: Object, + user: User, + options: Options = {} +): UserPresentation => { ctx.cache.set(user.id, user); - return { - id: user.id, - username: user.username, - name: user.name, - email: user.email, - avatarUrl: - user.avatarUrl || (user.slackData ? user.slackData.image_192 : null), - }; -} + const userData = {}; + userData.id = user.id; + userData.username = user.username; + userData.name = user.name; + userData.avatarUrl = + user.avatarUrl || (user.slackData ? user.slackData.image_192 : null); -export default present; + if (options.includeDetails) { + userData.isAdmin = user.isAdmin; + userData.email = user.email; + } + + return userData; +}; diff --git a/server/sequelize.js b/server/sequelize.js index 4c0275ab..83c05954 100644 --- a/server/sequelize.js +++ b/server/sequelize.js @@ -8,8 +8,10 @@ const secretKey = process.env.SECRET_KEY; export const encryptedFields = EncryptedField(Sequelize, secretKey); export const DataTypes = Sequelize; +export const Op = Sequelize.Op; export const sequelize = new Sequelize(process.env.DATABASE_URL, { logging: debug('sql'), typeValidation: true, + operatorsAliases: false, }); diff --git a/server/test/support.js b/server/test/support.js index 33f91509..7b1564e3 100644 --- a/server/test/support.js +++ b/server/test/support.js @@ -36,6 +36,21 @@ const seed = async () => { }, }); + const admin = await User.create({ + id: 'fa952cff-fa64-4d42-a6ea-6955c9689046', + email: 'admin@example.com', + username: 'admin', + name: 'Admin User', + password: 'test123!', + teamId: team.id, + isAdmin: true, + slackId: 'U2399UF1P', + slackData: { + id: 'U2399UF1P', + image_192: 'http://example.com/avatar.png', + }, + }); + let collection = await Collection.create({ id: '26fde1d4-0050-428f-9f0b-0bf77f8bdf62', name: 'Collection', @@ -59,6 +74,7 @@ const seed = async () => { return { user, + admin, collection, document, team, diff --git a/yarn.lock b/yarn.lock index 5850768e..68962d6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3611,9 +3611,9 @@ generic-pool@2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-2.4.3.tgz#780c36f69dfad05a5a045dd37be7adca11a4f6ff" -generic-pool@^3.1.6: - version "3.1.7" - resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.1.7.tgz#dac22b2c7a7a04e41732f7d8d2d25a303c88f662" +generic-pool@^3.1.8: + version "3.2.0" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.2.0.tgz#c1d485ecbd6f18c0513d4741d098a6715eaeeca8" get-caller-file@^1.0.1: version "1.0.2" @@ -8359,12 +8359,12 @@ restore-cursor@^1.0.1: exit-hook "^1.0.0" onetime "^1.0.0" -retry-as-promised@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/retry-as-promised/-/retry-as-promised-2.3.0.tgz#27bf5ccd999932b31665696825cf3630c27c562d" +retry-as-promised@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/retry-as-promised/-/retry-as-promised-2.3.2.tgz#cd974ee4fd9b5fe03cbf31871ee48221c07737b7" dependencies: bluebird "^3.4.6" - debug "^2.2.0" + debug "^2.6.9" right-align@^0.1.1: version "0.1.3" @@ -8527,26 +8527,26 @@ sequelize-encrypted@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/sequelize-encrypted/-/sequelize-encrypted-0.1.0.tgz#f9c7a94dc1b4413e1347a49f06cd07b7f3bf9916" -sequelize@^4.3.1: - version "4.8.0" - resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-4.8.0.tgz#1987a97deceb749da7e25cd27059adb69dbf81c2" +sequelize@4.28.6: + version "4.28.6" + resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-4.28.6.tgz#44b4b69f550bc53f41135bf8db73c5d492cb7e64" dependencies: bluebird "^3.4.6" cls-bluebird "^2.0.1" debug "^3.0.0" depd "^1.1.0" dottie "^2.0.0" - generic-pool "^3.1.6" + generic-pool "^3.1.8" inflection "1.12.0" lodash "^4.17.1" moment "^2.13.0" moment-timezone "^0.5.4" - retry-as-promised "^2.0.0" + retry-as-promised "^2.3.1" semver "^5.0.1" terraformer-wkt-parser "^1.1.2" toposort-class "^1.0.1" uuid "^3.0.0" - validator "^8.0.0" + validator "^9.1.0" wkx "^0.4.1" sequencify@~0.0.7: @@ -9726,9 +9726,9 @@ validator@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/validator/-/validator-5.2.0.tgz#e66fb3ec352348c1f7232512328738d8d66a9689" -validator@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/validator/-/validator-8.1.0.tgz#89cf6b512ff71eba886afd8d10d47f8dc800eac0" +validator@^9.1.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-9.2.0.tgz#ad216eed5f37cac31a6fe00ceab1f6b88bded03e" value-equal@^0.4.0: version "0.4.0"