Merge pull request #496 from outline/jori/team-admin
Backend support for team admins
This commit is contained in:
@@ -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",
|
||||
@@ -188,13 +188,13 @@
|
||||
"webpack": "1.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-jest": "^20.0.0",
|
||||
"babel-jest": "22",
|
||||
"enzyme": "2.8.2",
|
||||
"enzyme-to-json": "^1.5.1",
|
||||
"fetch-test-server": "^1.1.0",
|
||||
"flow-bin": "^0.49.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest-cli": "^20.0.0",
|
||||
"jest-cli": "22",
|
||||
"koa-webpack-dev-middleware": "1.4.5",
|
||||
"koa-webpack-hot-middleware": "1.0.3",
|
||||
"lint-staged": "^3.4.0",
|
||||
@@ -203,4 +203,4 @@
|
||||
"raf": "^3.4.0",
|
||||
"react-test-renderer": "^16.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
105
server/api/__snapshots__/team.test.js.snap
Normal file
105
server/api/__snapshots__/team.test.js.snap
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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 for detailed info 1`] = `
|
||||
Object {
|
||||
"data": Array [
|
||||
Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
|
||||
"name": "Admin User",
|
||||
"username": "admin",
|
||||
},
|
||||
Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"name": "User 1",
|
||||
"username": "user1",
|
||||
},
|
||||
],
|
||||
"ok": true,
|
||||
"pagination": Object {
|
||||
"limit": 15,
|
||||
"nextPath": "/api/team.users?limit=15&offset=15",
|
||||
"offset": 0,
|
||||
},
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
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,
|
||||
}
|
||||
`;
|
||||
@@ -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",
|
||||
@@ -36,7 +35,6 @@ exports[`#user.update should update user profile information 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"name": "New name",
|
||||
"username": "user1",
|
||||
|
||||
@@ -8,8 +8,9 @@ import { presentApiKey } from '../presenters';
|
||||
import { ApiKey } from '../models';
|
||||
|
||||
const router = new Router();
|
||||
router.use(auth());
|
||||
|
||||
router.post('apiKeys.create', auth(), async ctx => {
|
||||
router.post('apiKeys.create', async ctx => {
|
||||
const { name } = ctx.body;
|
||||
ctx.assertPresent(name, 'name is required');
|
||||
|
||||
@@ -25,7 +26,7 @@ router.post('apiKeys.create', auth(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('apiKeys.list', auth(), pagination(), async ctx => {
|
||||
router.post('apiKeys.list', pagination(), async ctx => {
|
||||
const user = ctx.state.user;
|
||||
const keys = await ApiKey.findAll({
|
||||
where: {
|
||||
@@ -46,7 +47,7 @@ router.post('apiKeys.list', auth(), pagination(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('apiKeys.delete', auth(), async ctx => {
|
||||
router.post('apiKeys.delete', async ctx => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -27,7 +27,7 @@ router.post('auth.slack', async ctx => {
|
||||
|
||||
let user = await User.findOne({ where: { slackId: data.user.id } });
|
||||
let team = await Team.findOne({ where: { slackId: data.team.id } });
|
||||
const teamExisted = !!team;
|
||||
const isFirstUser = !team;
|
||||
|
||||
if (team) {
|
||||
team.name = data.team.name;
|
||||
@@ -51,12 +51,17 @@ router.post('auth.slack', async ctx => {
|
||||
name: data.user.name,
|
||||
email: data.user.email,
|
||||
teamId: team.id,
|
||||
isAdmin: isFirstUser,
|
||||
slackData: data.user,
|
||||
slackAccessToken: data.access_token,
|
||||
});
|
||||
|
||||
// Set initial avatar
|
||||
await user.updateAvatar();
|
||||
await user.save();
|
||||
}
|
||||
|
||||
if (!teamExisted) {
|
||||
if (isFirstUser) {
|
||||
await team.createFirstCollection(user.id);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ import documents from './documents';
|
||||
import views from './views';
|
||||
import hooks from './hooks';
|
||||
import apiKeys from './apiKeys';
|
||||
import team from './team';
|
||||
|
||||
import validation from './middlewares/validation';
|
||||
import methodOverride from '../middlewares/methodOverride';
|
||||
@@ -64,6 +65,7 @@ router.use('/', documents.routes());
|
||||
router.use('/', views.routes());
|
||||
router.use('/', hooks.routes());
|
||||
router.use('/', apiKeys.routes());
|
||||
router.use('/', team.routes());
|
||||
|
||||
// Router is embedded in a Koa application wrapper, because koa-router does not
|
||||
// allow middleware to catch any routes which were not explicitly defined.
|
||||
|
||||
@@ -5,7 +5,17 @@ import { type Context } from 'koa';
|
||||
|
||||
import { User, ApiKey } from '../../models';
|
||||
|
||||
export default function auth({ require = true }: { require?: boolean } = {}) {
|
||||
type AuthOptions = {
|
||||
require?: boolean,
|
||||
adminOnly?: boolean,
|
||||
};
|
||||
|
||||
export default function auth(options: AuthOptions = {}) {
|
||||
options = {
|
||||
require: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
return async function authMiddleware(
|
||||
ctx: Context,
|
||||
next: () => Promise<void>
|
||||
@@ -25,8 +35,7 @@ export default function auth({ require = true }: { require?: boolean } = {}) {
|
||||
} else {
|
||||
if (require) {
|
||||
throw httpErrors.Unauthorized(
|
||||
`Bad Authorization header format. \
|
||||
Format is "Authorization: Bearer <token>"\n`
|
||||
`Bad Authorization header format. Format is "Authorization: Bearer <token>"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,13 +66,13 @@ export default function auth({ require = true }: { require?: boolean } = {}) {
|
||||
throw httpErrors.Unauthorized('Invalid API key');
|
||||
}
|
||||
|
||||
if (!apiKey) throw httpErrors.Unauthorized('Invalid token');
|
||||
if (!apiKey) throw httpErrors.Unauthorized('Invalid API key');
|
||||
|
||||
user = await User.findOne({
|
||||
where: { id: apiKey.userId },
|
||||
});
|
||||
|
||||
if (!user) throw httpErrors.Unauthorized('Invalid token');
|
||||
if (!user) throw httpErrors.Unauthorized('Invalid API key');
|
||||
} else {
|
||||
// JWT
|
||||
// Get user without verifying payload signature
|
||||
@@ -87,6 +96,10 @@ export default function auth({ require = true }: { require?: boolean } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.adminOnly && !user.isAdmin) {
|
||||
throw httpErrors.Forbidden('Only available for admins');
|
||||
}
|
||||
|
||||
ctx.state.token = token;
|
||||
ctx.state.user = user;
|
||||
// $FlowFixMe
|
||||
|
||||
197
server/api/middlewares/authentication.test.js
Normal file
197
server/api/middlewares/authentication.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import { flushdb, seed } from '../../test/support';
|
||||
import ApiKey from '../../models/ApiKey';
|
||||
import randomstring from 'randomstring';
|
||||
import auth from './authentication';
|
||||
|
||||
beforeEach(flushdb);
|
||||
|
||||
describe('Authentication middleware', async () => {
|
||||
describe('with JWT', () => {
|
||||
it('should authenticate with correct token', async () => {
|
||||
const state = {};
|
||||
const { user } = await seed();
|
||||
const authMiddleware = auth();
|
||||
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
expect(state.user.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it('should return error with invalid token', async () => {
|
||||
const state = {};
|
||||
const { user } = await seed();
|
||||
const authMiddleware = auth();
|
||||
|
||||
try {
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => `Bearer ${user.getJwtToken()}error`),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Invalid token');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('with API key', () => {
|
||||
it('should authenticate user with valid API key', async () => {
|
||||
const state = {};
|
||||
const { user } = await seed();
|
||||
const authMiddleware = auth();
|
||||
const key = await ApiKey.create({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => `Bearer ${key.secret}`),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
expect(state.user.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it('should return error with invalid API key', async () => {
|
||||
const state = {};
|
||||
const authMiddleware = auth();
|
||||
|
||||
try {
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => `Bearer ${randomstring.generate(38)}`),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Invalid API key');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminOnly', () => {
|
||||
it('should work if user is an admin', async () => {
|
||||
const state = {};
|
||||
const { user } = await seed();
|
||||
const authMiddleware = auth({ adminOnly: true });
|
||||
user.isAdmin = true;
|
||||
await user.save();
|
||||
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
expect(state.user.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it('should raise 403 if user is not an admin', async () => {
|
||||
const { user } = await seed();
|
||||
const authMiddleware = auth({ adminOnly: true });
|
||||
user.idAdmin = true;
|
||||
await user.save();
|
||||
|
||||
try {
|
||||
await authMiddleware({
|
||||
request: {
|
||||
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Only available for admins');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error message if no auth token is available', async () => {
|
||||
const state = {};
|
||||
const authMiddleware = auth();
|
||||
|
||||
try {
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => 'error'),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'Bad Authorization header format. Format is "Authorization: Bearer <token>"'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow passing auth token as a GET param', async () => {
|
||||
const state = {};
|
||||
const { user } = await seed();
|
||||
const authMiddleware = auth();
|
||||
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => null),
|
||||
query: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
},
|
||||
body: {},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
expect(state.user.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it('should allow passing auth token in body params', async () => {
|
||||
const state = {};
|
||||
const { user } = await seed();
|
||||
const authMiddleware = auth();
|
||||
|
||||
await authMiddleware(
|
||||
{
|
||||
request: {
|
||||
get: jest.fn(() => null),
|
||||
},
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
},
|
||||
jest.fn()
|
||||
);
|
||||
expect(state.user.id).toEqual(user.id);
|
||||
});
|
||||
});
|
||||
71
server/api/team.js
Normal file
71
server/api/team.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// @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.post('team.users', auth(), 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(listUser =>
|
||||
presentUser(ctx, listUser, { includeDetails: user.isAdmin })
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
router.post('team.addAdmin', auth({ adminOnly: true }), 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', auth({ adminOnly: true }), 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;
|
||||
108
server/api/team.test.js
Normal file
108
server/api/team.test.js
Normal file
@@ -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 for detailed info', 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(200);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,13 @@ import auth from './middlewares/authentication';
|
||||
import { presentUser } from '../presenters';
|
||||
|
||||
const router = new Router();
|
||||
router.use(auth());
|
||||
|
||||
router.post('user.info', auth(), async ctx => {
|
||||
router.post('user.info', async ctx => {
|
||||
ctx.body = { data: await presentUser(ctx, ctx.state.user) };
|
||||
});
|
||||
|
||||
router.post('user.update', auth(), async ctx => {
|
||||
router.post('user.update', async ctx => {
|
||||
const { user } = ctx.state;
|
||||
const { name, avatarUrl } = ctx.body;
|
||||
const endpoint = publicS3Endpoint();
|
||||
@@ -28,7 +29,7 @@ router.post('user.update', auth(), async ctx => {
|
||||
ctx.body = { data: await presentUser(ctx, user) };
|
||||
});
|
||||
|
||||
router.post('user.s3Upload', auth(), async ctx => {
|
||||
router.post('user.s3Upload', async ctx => {
|
||||
const { filename, kind, size } = ctx.body;
|
||||
ctx.assertPresent(filename, 'filename is required');
|
||||
ctx.assertPresent(kind, 'kind is required');
|
||||
|
||||
@@ -6,8 +6,9 @@ import { presentView } from '../presenters';
|
||||
import { View, Document } from '../models';
|
||||
|
||||
const router = new Router();
|
||||
router.use(auth());
|
||||
|
||||
router.post('views.list', auth(), async ctx => {
|
||||
router.post('views.list', async ctx => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
@@ -36,7 +37,7 @@ router.post('views.list', auth(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('views.create', auth(), async ctx => {
|
||||
router.post('views.create', async ctx => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
|
||||
23
server/migrations/20171225143838-set-admins.js
Normal file
23
server/migrations/20171225143838-set-admins.js
Normal file
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
const [teams, metaData] = await queryInterface.sequelize.query(`SELECT * FROM teams`);
|
||||
const teamIds = teams.map(team => team.id);
|
||||
await Promise.all(teamIds.map(async teamId => {
|
||||
await queryInterface.sequelize.query(`
|
||||
update users
|
||||
set "isAdmin" = true
|
||||
where id in (
|
||||
select id
|
||||
from users
|
||||
where "teamId" = '${teamId}'
|
||||
order by "createdAt" asc
|
||||
limit 1
|
||||
);
|
||||
`);
|
||||
}));
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// no-op
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -463,6 +463,43 @@ export default function Pricing() {
|
||||
</Description>
|
||||
<Arguments pagination />
|
||||
</Method>
|
||||
|
||||
<Method method="team.users" label="List team's users">
|
||||
<Description>
|
||||
List team`s users. This endpoint is only available for admin
|
||||
users.
|
||||
</Description>
|
||||
<Arguments pagination />
|
||||
</Method>
|
||||
|
||||
<Method method="team.addAdmin" label="Promote a new admin user">
|
||||
<Description>
|
||||
Promote a user to be a team admin. This endpoint is only available
|
||||
for admin users.
|
||||
</Description>
|
||||
<Arguments pagination>
|
||||
<Argument
|
||||
id="user"
|
||||
description="User ID to be promoted"
|
||||
required
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="team.removeAdmin" label="Demote existing admin user">
|
||||
<Description>
|
||||
Demote existing team admin if there are more than one as one admin
|
||||
is always required. This endpoint is only available for admin
|
||||
users.
|
||||
</Description>
|
||||
<Arguments pagination>
|
||||
<Argument
|
||||
id="user"
|
||||
description="User ID to be demoted"
|
||||
required
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
</Methods>
|
||||
</Container>
|
||||
</Grid>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user