Base model refactor (#810)

* Big upgrades

* WIP: Stash

* Stash, 30 flow errors left

* Downgrade mobx

* WIP

* When I understand the difference between class and instance methods

* 💚

* Fixes: File import
Model saving edge cases
pinning and starring docs
Collection editing
Upgrade mobx devtools

* Notification settings saving works

* Disabled settings

* Document mailer

* Working notifications

* Colletion created notification
Ensure not notified for own actions

* Tidy up

* Document updated event only for document creation
Add indexes
Notification setting on user creation

* Commentary

* Fixed: Notification setting on signup

* Fix document move / duplicate stale data
Add BaseModel.refresh method

* Fixes: Title in sidebar not updated after editing document

* 💚

* Improve / restore error handling
Better handle offline errors

* 👕
This commit is contained in:
Tom Moor
2018-12-04 22:24:30 -08:00
committed by GitHub
parent 67cd250316
commit 8cbcb77486
222 changed files with 2273 additions and 2361 deletions

View File

@@ -1,63 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#team.users should require admin for detailed info 1`] = `
Object {
"data": Array [
Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
"name": "Admin User",
"username": "admin",
},
Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"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",
"createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"name": "User 1",
"username": "user1",
},
Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"email": "admin@example.com",
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
"isAdmin": true,
"isSuspended": false,
"name": "Admin User",
"username": "admin",
},
],
"ok": true,
"pagination": Object {
"limit": 15,
"nextPath": "/api/team.users?limit=15&offset=15",
"offset": 0,
},
"status": 200,
}
`;

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#user.activate should activate a suspended user 1`] = `
exports[`#users.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
@@ -17,7 +17,7 @@ Object {
}
`;
exports[`#user.activate should require admin 1`] = `
exports[`#users.activate should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
@@ -26,7 +26,7 @@ Object {
}
`;
exports[`#user.delete should require authentication 1`] = `
exports[`#users.delete should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
@@ -35,7 +35,7 @@ Object {
}
`;
exports[`#user.demote should demote an admin 1`] = `
exports[`#users.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
@@ -52,7 +52,7 @@ Object {
}
`;
exports[`#user.demote should require admin 1`] = `
exports[`#users.demote should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
@@ -61,7 +61,7 @@ Object {
}
`;
exports[`#user.demote shouldn't demote admins if only one available 1`] = `
exports[`#users.demote shouldn't demote admins if only one available 1`] = `
Object {
"error": "validation_error",
"message": "At least one admin is required",
@@ -70,7 +70,69 @@ Object {
}
`;
exports[`#user.promote should promote a new admin 1`] = `
exports[`#users.list should require admin for detailed info 1`] = `
Object {
"data": Array [
Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
"name": "Admin User",
"username": "admin",
},
Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"name": "User 1",
"username": "user1",
},
],
"ok": true,
"pagination": Object {
"limit": 15,
"nextPath": "/api/users.list?limit=15&offset=15",
"offset": 0,
},
"status": 200,
}
`;
exports[`#users.list should return teams paginated user list 1`] = `
Object {
"data": Array [
Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"name": "User 1",
"username": "user1",
},
Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"email": "admin@example.com",
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
"isAdmin": true,
"isSuspended": false,
"name": "Admin User",
"username": "admin",
},
],
"ok": true,
"pagination": Object {
"limit": 15,
"nextPath": "/api/users.list?limit=15&offset=15",
"offset": 0,
},
"status": 200,
}
`;
exports[`#users.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
@@ -87,7 +149,7 @@ Object {
}
`;
exports[`#user.promote should require admin 1`] = `
exports[`#users.promote should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
@@ -96,7 +158,7 @@ Object {
}
`;
exports[`#user.suspend should require admin 1`] = `
exports[`#users.suspend should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
@@ -105,7 +167,7 @@ Object {
}
`;
exports[`#user.suspend should suspend an user 1`] = `
exports[`#users.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
@@ -122,7 +184,7 @@ Object {
}
`;
exports[`#user.suspend shouldn't allow suspending the user themselves 1`] = `
exports[`#users.suspend shouldn't allow suspending the user themselves 1`] = `
Object {
"error": "validation_error",
"message": "Unable to suspend the current user",
@@ -131,7 +193,7 @@ Object {
}
`;
exports[`#user.update should require authentication 1`] = `
exports[`#users.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
@@ -140,7 +202,7 @@ Object {
}
`;
exports[`#user.update should update user profile information 1`] = `
exports[`#users.update should update user profile information 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",

View File

@@ -4,7 +4,7 @@ import Koa from 'koa';
import Router from 'koa-router';
import auth from './auth';
import user from './user';
import users from './users';
import collections from './collections';
import documents from './documents';
import views from './views';
@@ -13,6 +13,7 @@ import apiKeys from './apiKeys';
import shares from './shares';
import team from './team';
import integrations from './integrations';
import notificationSettings from './notificationSettings';
import errorHandling from './middlewares/errorHandling';
import validation from '../middlewares/validation';
@@ -33,7 +34,7 @@ api.use(apiWrapper());
// routes
router.use('/', auth.routes());
router.use('/', user.routes());
router.use('/', users.routes());
router.use('/', collections.routes());
router.use('/', documents.routes());
router.use('/', views.routes());
@@ -42,6 +43,7 @@ router.use('/', apiKeys.routes());
router.use('/', shares.routes());
router.use('/', team.routes());
router.use('/', integrations.routes());
router.use('/', notificationSettings.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.

View File

@@ -0,0 +1,60 @@
// @flow
import Router from 'koa-router';
import auth from '../middlewares/authentication';
import { NotificationSetting } from '../models';
import { presentNotificationSetting } from '../presenters';
import policy from '../policies';
const { authorize } = policy;
const router = new Router();
router.post('notificationSettings.create', auth(), async ctx => {
const { event } = ctx.body;
ctx.assertPresent(event, 'event is required');
const user = ctx.state.user;
authorize(user, 'create', NotificationSetting);
const [setting] = await NotificationSetting.findOrCreate({
where: {
userId: user.id,
teamId: user.teamId,
event,
},
});
ctx.body = {
data: presentNotificationSetting(ctx, setting),
};
});
router.post('notificationSettings.list', auth(), async ctx => {
const user = ctx.state.user;
const settings = await NotificationSetting.findAll({
where: {
userId: user.id,
},
});
ctx.body = {
data: settings.map(setting => presentNotificationSetting(ctx, setting)),
};
});
router.post('notificationSettings.delete', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const setting = await NotificationSetting.findById(id);
authorize(user, 'delete', setting);
await setting.destroy();
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -1,11 +1,10 @@
// @flow
import Router from 'koa-router';
import { User, Team } from '../models';
import { Team } from '../models';
import { publicS3Endpoint } from '../utils/s3';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentUser, presentTeam } from '../presenters';
import { presentTeam } from '../presenters';
import policy from '../policies';
const { authorize } = policy;
@@ -33,24 +32,4 @@ router.post('team.update', auth(), async ctx => {
ctx.body = { data: await presentTeam(ctx, team) };
});
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 })
),
};
});
export default router;

View File

@@ -1,6 +1,5 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import { flushdb, seed } from '../test/support';
@@ -10,31 +9,6 @@ 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.update', async () => {
it('should update team details', async () => {
const { admin } = await seed();

View File

@@ -5,17 +5,38 @@ import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3';
import { ValidationError } from '../errors';
import { Event, User, Team } 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('user.info', auth(), async ctx => {
router.post('users.list', 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('users.info', auth(), async ctx => {
ctx.body = { data: await presentUser(ctx, ctx.state.user) };
});
router.post('user.update', auth(), async ctx => {
router.post('users.update', auth(), async ctx => {
const { user } = ctx.state;
const { name, avatarUrl } = ctx.body;
const endpoint = publicS3Endpoint();
@@ -30,7 +51,7 @@ router.post('user.update', auth(), async ctx => {
ctx.body = { data: await presentUser(ctx, user, { includeDetails: true }) };
});
router.post('user.s3Upload', auth(), async ctx => {
router.post('users.s3Upload', auth(), async ctx => {
const { filename, kind, size } = ctx.body;
ctx.assertPresent(filename, 'filename is required');
ctx.assertPresent(kind, 'kind is required');
@@ -79,7 +100,7 @@ router.post('user.s3Upload', auth(), async ctx => {
// Admin specific
router.post('user.promote', auth(), async ctx => {
router.post('users.promote', auth(), async ctx => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'id is required');
@@ -95,7 +116,7 @@ router.post('user.promote', auth(), async ctx => {
};
});
router.post('user.demote', auth(), async ctx => {
router.post('users.demote', auth(), async ctx => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'id is required');
@@ -120,7 +141,7 @@ router.post('user.demote', auth(), async ctx => {
*
* Admin can suspend users to reduce the number of accounts on their billing plan
*/
router.post('user.suspend', auth(), async ctx => {
router.post('users.suspend', auth(), async ctx => {
const admin = ctx.state.user;
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
@@ -147,7 +168,7 @@ router.post('user.suspend', auth(), async ctx => {
* 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 => {
router.post('users.activate', auth(), async ctx => {
const admin = ctx.state.user;
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
@@ -164,7 +185,7 @@ router.post('user.activate', auth(), async ctx => {
};
});
router.post('user.delete', auth(), async ctx => {
router.post('users.delete', auth(), async ctx => {
const { confirmation } = ctx.body;
ctx.assertPresent(confirmation, 'confirmation is required');

View File

@@ -10,10 +10,35 @@ const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe('#user.info', async () => {
describe('#users.list', async () => {
it('should return teams paginated user list', async () => {
const { admin } = await seed();
const res = await server.post('/api/users.list', {
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/users.list', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
});
describe('#users.info', async () => {
it('should return known user', async () => {
const user = await buildUser();
const res = await server.post('/api/user.info', {
const res = await server.post('/api/users.info', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
@@ -24,15 +49,15 @@ describe('#user.info', async () => {
});
it('should require authentication', async () => {
const res = await server.post('/api/user.info');
const res = await server.post('/api/users.info');
expect(res.status).toEqual(401);
});
});
describe('#user.delete', async () => {
describe('#users.delete', async () => {
it('should not allow deleting without confirmation', async () => {
const user = await buildUser();
const res = await server.post('/api/user.delete', {
const res = await server.post('/api/users.delete', {
body: { token: user.getJwtToken() },
});
expect(res.status).toEqual(400);
@@ -40,7 +65,7 @@ describe('#user.delete', async () => {
it('should allow deleting last admin if only user', async () => {
const user = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.delete', {
const res = await server.post('/api/users.delete', {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(200);
@@ -50,7 +75,7 @@ describe('#user.delete', async () => {
const user = await buildUser({ isAdmin: true });
await buildUser({ teamId: user.teamId, isAdmin: false });
const res = await server.post('/api/user.delete', {
const res = await server.post('/api/users.delete', {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(400);
@@ -58,14 +83,14 @@ describe('#user.delete', async () => {
it('should allow deleting user account with confirmation', async () => {
const user = await buildUser();
const res = await server.post('/api/user.delete', {
const res = await server.post('/api/users.delete', {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(200);
});
it('should require authentication', async () => {
const res = await server.post('/api/user.delete');
const res = await server.post('/api/users.delete');
const body = await res.json();
expect(res.status).toEqual(401);
@@ -73,10 +98,10 @@ describe('#user.delete', async () => {
});
});
describe('#user.update', async () => {
describe('#users.update', async () => {
it('should update user profile information', async () => {
const { user } = await seed();
const res = await server.post('/api/user.update', {
const res = await server.post('/api/users.update', {
body: { token: user.getJwtToken(), name: 'New name' },
});
const body = await res.json();
@@ -86,7 +111,7 @@ describe('#user.update', async () => {
});
it('should require authentication', async () => {
const res = await server.post('/api/user.update');
const res = await server.post('/api/users.update');
const body = await res.json();
expect(res.status).toEqual(401);
@@ -94,11 +119,11 @@ describe('#user.update', async () => {
});
});
describe('#user.promote', async () => {
describe('#users.promote', async () => {
it('should promote a new admin', async () => {
const { admin, user } = await seed();
const res = await server.post('/api/user.promote', {
const res = await server.post('/api/users.promote', {
body: { token: admin.getJwtToken(), id: user.id },
});
const body = await res.json();
@@ -109,7 +134,7 @@ describe('#user.promote', async () => {
it('should require admin', async () => {
const user = await buildUser();
const res = await server.post('/api/user.promote', {
const res = await server.post('/api/users.promote', {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();
@@ -119,12 +144,12 @@ describe('#user.promote', async () => {
});
});
describe('#user.demote', async () => {
describe('#users.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', {
const res = await server.post('/api/users.demote', {
body: {
token: admin.getJwtToken(),
id: user.id,
@@ -139,7 +164,7 @@ describe('#user.demote', async () => {
it("shouldn't demote admins if only one available ", async () => {
const admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.demote', {
const res = await server.post('/api/users.demote', {
body: {
token: admin.getJwtToken(),
id: admin.id,
@@ -153,7 +178,7 @@ describe('#user.demote', async () => {
it('should require admin', async () => {
const user = await buildUser();
const res = await server.post('/api/user.promote', {
const res = await server.post('/api/users.promote', {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();
@@ -163,11 +188,11 @@ describe('#user.demote', async () => {
});
});
describe('#user.suspend', async () => {
describe('#users.suspend', async () => {
it('should suspend an user', async () => {
const { admin, user } = await seed();
const res = await server.post('/api/user.suspend', {
const res = await server.post('/api/users.suspend', {
body: {
token: admin.getJwtToken(),
id: user.id,
@@ -181,7 +206,7 @@ describe('#user.suspend', async () => {
it("shouldn't allow suspending the user themselves", async () => {
const admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.suspend', {
const res = await server.post('/api/users.suspend', {
body: {
token: admin.getJwtToken(),
id: admin.id,
@@ -195,7 +220,7 @@ describe('#user.suspend', async () => {
it('should require admin', async () => {
const user = await buildUser();
const res = await server.post('/api/user.suspend', {
const res = await server.post('/api/users.suspend', {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();
@@ -205,7 +230,7 @@ describe('#user.suspend', async () => {
});
});
describe('#user.activate', async () => {
describe('#users.activate', async () => {
it('should activate a suspended user', async () => {
const { admin, user } = await seed();
await user.update({
@@ -214,7 +239,7 @@ describe('#user.activate', async () => {
});
expect(user.isSuspended).toBe(true);
const res = await server.post('/api/user.activate', {
const res = await server.post('/api/users.activate', {
body: {
token: admin.getJwtToken(),
id: user.id,
@@ -228,7 +253,7 @@ describe('#user.activate', async () => {
it('should require admin', async () => {
const user = await buildUser();
const res = await server.post('/api/user.activate', {
const res = await server.post('/api/users.activate', {
body: { token: user.getJwtToken(), id: user.id },
});
const body = await res.json();