feat: Memberships (#1032)

* WIP

* feat: Add collection.memberships endpoint

* feat: Add ability to filter collection.memberships with query

* WIP

* Merge stashed work

* feat: Add ability to filter memberships by permission

* continued refactoring

* paginated list component

* Collection member management

* fix: Incorrect policy data sent down after collection.update

* Reduce duplication, add empty state

* cleanup

* fix: Modal close should be a real button

* fix: Allow opening edit from modal

* fix: remove unused methods

* test: fix

* Passing test suite

* Refactor

* fix: Flow UI errors

* test: Add collections.update tests

* lint

* test: moar tests

* fix: Missing scopes, more missing tests

* fix: Handle collection privacy change over socket

* fix: More membership scopes

* fix: view endpoint permissions

* fix: respond to privacy change on socket event

* policy driven menus

* fix: share endpoint policies

* chore: Use policies to drive documents UI

* alignment

* fix: Header height

* fix: Correct behavior when collection becomes private

* fix: Header height for read-only collection

* send id's over socket instead of serialized objects

* fix: Remote policy change

* fix: reduce collection fetching

* More websocket efficiencies

* fix: Document collection pinning

* fix: Restored ability to edit drafts
fix: Removed ability to star drafts

* fix: Require write permissions to pin doc to collection

* fix: Header title overlaying document actions at small screen sizes

* fix: Jank on load caused by previous commit

* fix: Double collection fetch post-publish

* fix: Hide publish button if draft is in no longer accessible collection

* fix: Always allow deleting drafts
fix: Improved handling of deleted documents

* feat: Show collections in drafts view
feat: Show more obvious 'draft' badge on documents

* fix: incorrect policies after publish to private collection

* fix: Duplicating a draft publishes it
This commit is contained in:
Tom Moor
2019-10-05 18:42:03 -07:00
committed by GitHub
parent 4164fc178c
commit b42e9737b6
72 changed files with 2360 additions and 765 deletions

View File

@@ -62,6 +62,15 @@ Object {
}
`;
exports[`#collections.memberships should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.remove_user should require user in team 1`] = `
Object {
"error": "authorization_error",
@@ -70,6 +79,15 @@ Object {
}
`;
exports[`#collections.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.users should require authentication 1`] = `
Object {
"error": "authentication_required",

View File

@@ -1,11 +1,17 @@
// @flow
import fs from 'fs';
import Router from 'koa-router';
import { Op } from '../sequelize';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentCollection, presentUser, presentPolicies } from '../presenters';
import {
presentCollection,
presentUser,
presentPolicies,
presentMembership,
} from '../presenters';
import { Collection, CollectionUser, Team, Event, User } from '../models';
import { ValidationError, InvalidRequestError } from '../errors';
import { ValidationError } from '../errors';
import { exportCollections } from '../logistics';
import { archiveCollection, archiveCollections } from '../utils/zip';
import policy from '../policies';
@@ -44,7 +50,7 @@ router.post('collections.create', auth(), async ctx => {
});
ctx.body = {
data: await presentCollection(collection),
data: presentCollection(collection),
policies: presentPolicies(user, [collection]),
};
});
@@ -54,11 +60,13 @@ router.post('collections.info', auth(), async ctx => {
ctx.assertUuid(id, 'id is required');
const user = ctx.state.user;
const collection = await Collection.findByPk(id);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(id);
authorize(user, 'read', collection);
ctx.body = {
data: await presentCollection(collection),
data: presentCollection(collection),
policies: presentPolicies(user, [collection]),
};
});
@@ -68,23 +76,33 @@ router.post('collections.add_user', auth(), async ctx => {
ctx.assertUuid(id, 'id is required');
ctx.assertUuid(userId, 'userId is required');
const collection = await Collection.findByPk(id);
const collection = await Collection.scope({
method: ['withMembership', ctx.state.user.id],
}).findByPk(id);
authorize(ctx.state.user, 'update', collection);
if (!collection.private) {
throw new InvalidRequestError('Collection must be private to add users');
}
const user = await User.findByPk(userId);
authorize(ctx.state.user, 'read', user);
await CollectionUser.create({
collectionId: id,
userId,
permission,
createdById: ctx.state.user.id,
let membership = await CollectionUser.findOne({
where: {
collectionId: id,
userId,
},
});
if (!membership) {
membership = await CollectionUser.create({
collectionId: id,
userId,
permission,
createdById: ctx.state.user.id,
});
} else if (permission) {
membership.permission = permission;
await membership.save();
}
await Event.create({
name: 'collections.add_user',
userId,
@@ -96,7 +114,10 @@ router.post('collections.add_user', auth(), async ctx => {
});
ctx.body = {
success: true,
data: {
users: [presentUser(user)],
memberships: [presentMembership(membership)],
},
};
});
@@ -105,13 +126,11 @@ router.post('collections.remove_user', auth(), async ctx => {
ctx.assertUuid(id, 'id is required');
ctx.assertUuid(userId, 'userId is required');
const collection = await Collection.findByPk(id);
const collection = await Collection.scope({
method: ['withMembership', ctx.state.user.id],
}).findByPk(id);
authorize(ctx.state.user, 'update', collection);
if (!collection.private) {
throw new InvalidRequestError('Collection must be private to remove users');
}
const user = await User.findByPk(userId);
authorize(ctx.state.user, 'read', user);
@@ -132,12 +151,16 @@ router.post('collections.remove_user', auth(), async ctx => {
};
});
// DEPRECATED: Use collection.memberships which has pagination, filtering and permissions
router.post('collections.users', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertUuid(id, 'id is required');
const collection = await Collection.findByPk(id);
authorize(ctx.state.user, 'read', collection);
const user = ctx.state.user;
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(id);
authorize(user, 'read', collection);
const users = await collection.getUsers();
@@ -146,12 +169,69 @@ router.post('collections.users', auth(), async ctx => {
};
});
router.post('collections.memberships', auth(), pagination(), async ctx => {
const { id, query, permission } = ctx.body;
ctx.assertUuid(id, 'id is required');
const user = ctx.state.user;
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(id);
authorize(user, 'read', collection);
let where = {
collectionId: id,
};
let userWhere;
if (query) {
userWhere = {
name: {
[Op.iLike]: `%${query}%`,
},
};
}
if (permission) {
where = {
...where,
permission,
};
}
const memberships = await CollectionUser.findAll({
where,
order: [['createdAt', 'DESC']],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
include: [
{
model: User,
as: 'user',
where: userWhere,
required: true,
},
],
});
ctx.body = {
pagination: ctx.state.pagination,
data: {
memberships: memberships.map(presentMembership),
users: memberships.map(membership => presentUser(membership.user)),
},
};
});
router.post('collections.export', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertUuid(id, 'id is required');
const user = ctx.state.user;
const collection = await Collection.findByPk(id);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(id);
authorize(user, 'export', collection);
const filePath = await archiveCollection(collection);
@@ -207,15 +287,20 @@ router.post('collections.exportAll', auth(), async ctx => {
router.post('collections.update', auth(), async ctx => {
const { id, name, description, color } = ctx.body;
const isPrivate = ctx.body.private;
ctx.assertPresent(name, 'name is required');
if (color)
if (color) {
ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)');
}
const user = ctx.state.user;
const collection = await Collection.findByPk(id);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(id);
authorize(user, 'update', collection);
// we're making this collection private right now, ensure that the current
// user has a read-write membership so that at least they can edit it
if (isPrivate && !collection.private) {
await CollectionUser.findOrCreate({
where: {
@@ -229,6 +314,8 @@ router.post('collections.update', auth(), async ctx => {
});
}
const isPrivacyChanged = isPrivate !== collection.private;
collection.name = name;
collection.description = description;
collection.color = color;
@@ -244,6 +331,16 @@ router.post('collections.update', auth(), async ctx => {
ip: ctx.request.ip,
});
// must reload to update collection membership for correct policy calculation
// if the privacy level has changed. Otherwise skip this query for speed.
if (isPrivacyChanged) {
await collection.reload({
scope: {
method: ['withMembership', user.id],
},
});
}
ctx.body = {
data: presentCollection(collection),
policies: presentPolicies(user, [collection]),
@@ -252,9 +349,10 @@ router.post('collections.update', auth(), async ctx => {
router.post('collections.list', auth(), pagination(), async ctx => {
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
let collections = await Collection.findAll({
let collections = await Collection.scope({
method: ['withMembership', user.id],
}).findAll({
where: {
teamId: user.teamId,
id: collectionIds,
@@ -264,15 +362,10 @@ router.post('collections.list', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
collections.map(async collection => await presentCollection(collection))
);
const policies = presentPolicies(user, collections);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
data: collections.map(presentCollection),
policies: presentPolicies(user, collections),
};
});
@@ -281,7 +374,9 @@ router.post('collections.delete', auth(), async ctx => {
const user = ctx.state.user;
ctx.assertUuid(id, 'id is required');
const collection = await Collection.findByPk(id);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(id);
authorize(user, 'delete', collection);
const total = await Collection.count();

View File

@@ -3,7 +3,7 @@ import TestServer from 'fetch-test-server';
import app from '../app';
import { flushdb, seed } from '../test/support';
import { buildUser, buildCollection } from '../test/factories';
import { Collection } from '../models';
import { Collection, CollectionUser } from '../models';
const server = new TestServer(app.callback());
beforeEach(flushdb);
@@ -28,6 +28,8 @@ describe('#collections.list', async () => {
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(collection.id);
expect(body.policies.length).toEqual(1);
expect(body.policies[0].abilities.read).toEqual(true);
});
it('should not return private collections not a member of', async () => {
@@ -60,11 +62,13 @@ describe('#collections.list', async () => {
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.policies.length).toEqual(2);
expect(body.policies[0].abilities.read).toEqual(true);
});
});
describe('#collections.export', async () => {
it('should require user to be a member', async () => {
it('should now allow export of private collection not a member', async () => {
const { user } = await seed();
const collection = await buildCollection({
private: true,
@@ -77,6 +81,25 @@ describe('#collections.export', async () => {
expect(res.status).toEqual(403);
});
it('should allow export of private collection', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read',
});
const res = await server.post('/api/collections.export', {
body: { token: user.getJwtToken(), id: collection.id },
});
expect(res.status).toEqual(200);
});
it('should require authentication', async () => {
const res = await server.post('/api/collections.export');
const body = await res.json();
@@ -272,12 +295,25 @@ describe('#collections.remove_user', async () => {
});
describe('#collections.users', async () => {
it('should return members in private collection', async () => {
it('should return users in private collection', async () => {
const { collection, user } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read',
});
const res = await server.post('/api/collections.users', {
body: { token: user.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
it('should require authentication', async () => {
@@ -298,6 +334,109 @@ describe('#collections.users', async () => {
});
});
describe('#collections.memberships', async () => {
it('should return members in private collection', async () => {
const { collection, user } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read_write',
});
const res = await server.post('/api/collections.memberships', {
body: { token: user.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.users.length).toEqual(1);
expect(body.data.users[0].id).toEqual(user.id);
expect(body.data.memberships.length).toEqual(1);
expect(body.data.memberships[0].permission).toEqual('read_write');
});
it('should allow filtering members in collection by name', async () => {
const { collection, user } = await seed();
const user2 = await buildUser({ name: "Won't find" });
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read_write',
});
await CollectionUser.create({
createdById: user2.id,
collectionId: collection.id,
userId: user2.id,
permission: 'read_write',
});
const res = await server.post('/api/collections.memberships', {
body: {
token: user.getJwtToken(),
id: collection.id,
query: user.name.slice(0, 3),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.users.length).toEqual(1);
expect(body.data.users[0].id).toEqual(user.id);
});
it('should allow filtering members in collection by permission', async () => {
const { collection, user } = await seed();
const user2 = await buildUser();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read_write',
});
await CollectionUser.create({
createdById: user2.id,
collectionId: collection.id,
userId: user2.id,
permission: 'maintainer',
});
const res = await server.post('/api/collections.memberships', {
body: {
token: user.getJwtToken(),
id: collection.id,
permission: 'maintainer',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.users.length).toEqual(1);
expect(body.data.users[0].id).toEqual(user2.id);
});
it('should require authentication', async () => {
const res = await server.post('/api/collections.memberships');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should require authorization', async () => {
const { collection } = await seed();
const user = await buildUser();
const res = await server.post('/api/collections.memberships', {
body: { token: user.getJwtToken(), id: collection.id },
});
expect(res.status).toEqual(403);
});
});
describe('#collections.info', async () => {
it('should return collection', async () => {
const { user, collection } = await seed();
@@ -321,6 +460,27 @@ describe('#collections.info', async () => {
expect(res.status).toEqual(403);
});
it('should allow user member of collection', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: 'read',
});
const res = await server.post('/api/collections.info', {
body: { token: user.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(collection.id);
});
it('should require authentication', async () => {
const res = await server.post('/api/collections.info');
const body = await res.json();
@@ -362,6 +522,128 @@ describe('#collections.create', async () => {
});
});
describe('#collections.update', async () => {
it('should require authentication', async () => {
const collection = await buildCollection();
const res = await server.post('/api/collections.update', {
body: { id: collection.id, name: 'Test' },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should require authorization', async () => {
const { collection } = await seed();
const user = await buildUser();
const res = await server.post('/api/collections.update', {
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
});
expect(res.status).toEqual(403);
});
it('allows editing non-private collection', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/collections.update', {
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe('Test');
expect(body.policies.length).toBe(1);
});
it('allows editing from non-private to private collection', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/collections.update', {
body: {
token: user.getJwtToken(),
id: collection.id,
private: true,
name: 'Test',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe('Test');
expect(body.data.private).toBe(true);
// ensure we return with a write level policy
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.update).toBe(true);
});
it('allows editing from private to non-private collection', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: 'read_write',
});
const res = await server.post('/api/collections.update', {
body: {
token: user.getJwtToken(),
id: collection.id,
private: false,
name: 'Test',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe('Test');
expect(body.data.private).toBe(false);
// ensure we return with a write level policy
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.update).toBe(true);
});
it('allows editing by read-write collection user', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: 'read_write',
});
const res = await server.post('/api/collections.update', {
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe('Test');
expect(body.policies.length).toBe(1);
});
it('does not allow editing by read-only collection user', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: 'read',
});
const res = await server.post('/api/collections.update', {
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
});
expect(res.status).toEqual(403);
});
});
describe('#collections.delete', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.delete');
@@ -380,7 +662,7 @@ describe('#collections.delete', async () => {
expect(res.status).toEqual(403);
});
it('should not delete last collection', async () => {
it('should not allow deleting last collection', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/collections.delete', {
body: { token: user.getJwtToken(), id: collection.id },

View File

@@ -53,7 +53,9 @@ router.post('documents.list', auth(), pagination(), async ctx => {
ctx.assertUuid(collectionId, 'collection must be a UUID');
where = { ...where, collectionId };
const collection = await Collection.findByPk(collectionId);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(collectionId);
authorize(user, 'read', collection);
// otherwise, filter by all collections the user has access to
@@ -78,7 +80,12 @@ router.post('documents.list', auth(), pagination(), async ctx => {
// add the users starred state to the response by default
const starredScope = { method: ['withStarred', user.id] };
const documents = await Document.scope('defaultScope', starredScope).findAll({
const collectionScope = { method: ['withCollection', user.id] };
const documents = await Document.scope(
'defaultScope',
starredScope,
collectionScope
).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
@@ -99,18 +106,24 @@ router.post('documents.list', auth(), pagination(), async ctx => {
});
router.post('documents.pinned', auth(), pagination(), async ctx => {
const { sort = 'updatedAt' } = ctx.body;
const collectionId = ctx.body.collection;
const { collectionId, sort = 'updatedAt' } = ctx.body;
let direction = ctx.body.direction;
if (direction !== 'ASC') direction = 'DESC';
ctx.assertUuid(collectionId, 'collection is required');
ctx.assertUuid(collectionId, 'collectionId is required');
const user = ctx.state.user;
const collection = await Collection.findByPk(collectionId);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(collectionId);
authorize(user, 'read', collection);
const starredScope = { method: ['withStarred', user.id] };
const documents = await Document.scope('defaultScope', starredScope).findAll({
const collectionScope = { method: ['withCollection', user.id] };
const documents = await Document.scope(
'defaultScope',
starredScope,
collectionScope
).findAll({
where: {
teamId: user.teamId,
collectionId,
@@ -269,7 +282,11 @@ router.post('documents.drafts', auth(), pagination(), async ctx => {
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
const documents = await Document.findAll({
const collectionScope = { method: ['withCollection', user.id] };
const documents = await Document.scope(
'defaultScope',
collectionScope
).findAll({
where: {
userId: user.id,
collectionId: collectionIds,
@@ -324,7 +341,10 @@ router.post('documents.info', auth({ required: false }), async ctx => {
}
document = share.document;
} else {
document = await Document.findByPk(id);
document = await Document.findByPk(
id,
user ? { userId: user.id } : undefined
);
authorize(user, 'read', document);
}
@@ -341,8 +361,9 @@ router.post('documents.revision', auth(), async ctx => {
ctx.assertPresent(id, 'id is required');
ctx.assertPresent(revisionId, 'revisionId is required');
const document = await Document.findByPk(id);
authorize(ctx.state.user, 'read', document);
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'read', document);
const revision = await Revision.findOne({
where: {
@@ -361,9 +382,10 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
let { id, sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
ctx.assertPresent(id, 'id is required');
const document = await Document.findByPk(id);
authorize(ctx.state.user, 'read', document);
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'read', document);
const revisions = await Revision.findAll({
where: { documentId: id },
@@ -383,7 +405,7 @@ router.post('documents.restore', auth(), async ctx => {
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id);
const document = await Document.findByPk(id, { userId: user.id });
if (document.archivedAt) {
authorize(user, 'unarchive', document);
@@ -439,7 +461,9 @@ router.post('documents.search', auth(), pagination(), async ctx => {
if (collectionId) {
ctx.assertUuid(collectionId, 'collectionId must be a UUID');
const collection = await Collection.findByPk(collectionId);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(collectionId);
authorize(user, 'read', collection);
}
@@ -486,10 +510,10 @@ router.post('documents.search', auth(), pagination(), async ctx => {
router.post('documents.pin', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id);
authorize(user, 'update', document);
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'pin', document);
document.pinnedById = user.id;
await document.save();
@@ -513,10 +537,10 @@ router.post('documents.pin', auth(), async ctx => {
router.post('documents.unpin', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id);
authorize(user, 'update', document);
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'unpin', document);
document.pinnedById = null;
await document.save();
@@ -540,9 +564,9 @@ router.post('documents.unpin', auth(), async ctx => {
router.post('documents.star', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id);
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'read', document);
await Star.findOrCreate({
@@ -563,9 +587,9 @@ router.post('documents.star', auth(), async ctx => {
router.post('documents.unstar', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id);
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'read', document);
await Star.destroy({
@@ -574,7 +598,7 @@ router.post('documents.unstar', auth(), async ctx => {
await Event.create({
name: 'documents.unstar',
modelId: document.id,
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
@@ -602,7 +626,9 @@ router.post('documents.create', auth(), async ctx => {
const user = ctx.state.user;
authorize(user, 'create', Document);
const collection = await Collection.findOne({
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findOne({
where: {
id: collectionId,
teamId: user.teamId,
@@ -618,7 +644,7 @@ router.post('documents.create', auth(), async ctx => {
collectionId: collection.id,
},
});
authorize(user, 'read', parentDocument);
authorize(user, 'read', parentDocument, { collection });
}
let document = await Document.create({
@@ -662,6 +688,7 @@ router.post('documents.create', auth(), async ctx => {
document = await Document.findOne({
where: { id: document.id, publishedAt: document.publishedAt },
});
document.collection = collection;
ctx.body = {
data: await presentDocument(document),
@@ -685,9 +712,8 @@ router.post('documents.update', auth(), async ctx => {
if (append) ctx.assertPresent(text, 'Text is required while appending');
const user = ctx.state.user;
const document = await Document.findByPk(id);
authorize(ctx.state.user, 'update', document);
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'update', document);
if (lastRevision && lastRevision !== document.revisionCount) {
throw new InvalidRequestError('Document has changed since last revision');
@@ -702,6 +728,7 @@ router.post('documents.update', auth(), async ctx => {
document.text = text;
}
document.lastModifiedById = user.id;
const { collection } = document;
let transaction;
try {
@@ -746,6 +773,8 @@ router.post('documents.update', auth(), async ctx => {
});
}
document.collection = collection;
ctx.body = {
data: await presentDocument(document),
policies: presentPolicies(user, [document]),
@@ -770,12 +799,10 @@ router.post('documents.move', auth(), async ctx => {
}
const user = ctx.state.user;
const document = await Document.findByPk(id);
const document = await Document.findByPk(id, { userId: user.id });
const { collection } = document;
authorize(user, 'move', document);
const collection = await Collection.findByPk(collectionId);
authorize(user, 'update', collection);
if (collection.type !== 'atlas' && parentDocumentId) {
throw new InvalidRequestError(
'Document cannot be nested in this collection type'
@@ -783,7 +810,7 @@ router.post('documents.move', auth(), async ctx => {
}
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId);
const parent = await Document.findByPk(parentDocumentId, user.id);
authorize(user, 'update', parent);
}
@@ -814,7 +841,7 @@ router.post('documents.archive', auth(), async ctx => {
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id);
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'archive', document);
await document.archive(user.id);
@@ -840,7 +867,7 @@ router.post('documents.delete', auth(), async ctx => {
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id);
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'delete', document);
await document.delete();

View File

@@ -1,7 +1,14 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '../app';
import { Document, View, Star, Revision, Backlink } from '../models';
import {
Document,
View,
Star,
Revision,
Backlink,
CollectionUser,
} from '../models';
import { flushdb, seed } from '../test/support';
import {
buildShare,
@@ -274,6 +281,30 @@ describe('#documents.list', async () => {
expect(body.data.length).toEqual(1);
});
it('should allow filtering to private collection', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read',
});
const res = await server.post('/api/documents.list', {
body: {
token: user.getJwtToken(),
collection: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
it('should return backlinks', async () => {
const { user, document } = await seed();
const anotherDoc = await buildDocument({
@@ -308,6 +339,64 @@ describe('#documents.list', async () => {
});
});
describe('#documents.pinned', async () => {
it('should return pinned documents', async () => {
const { user, document } = await seed();
document.pinnedById = user.id;
await document.save();
const res = await server.post('/api/documents.pinned', {
body: { token: user.getJwtToken(), collectionId: document.collectionId },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(document.id);
});
it('should return pinned documents in private collections member of', async () => {
const { user, collection, document } = await seed();
collection.private = true;
await collection.save();
document.pinnedById = user.id;
await document.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: 'read_write',
});
const res = await server.post('/api/documents.pinned', {
body: { token: user.getJwtToken(), collectionId: document.collectionId },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(document.id);
});
it('should not return pinned documents in private collections not a member of', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
const res = await server.post('/api/documents.pinned', {
body: { token: user.getJwtToken(), collectionId: collection.id },
});
expect(res.status).toEqual(403);
});
it('should require authentication', async () => {
const res = await server.post('/api/documents.pinned');
expect(res.status).toEqual(401);
});
});
describe('#documents.drafts', async () => {
it('should return unpublished documents', async () => {
const { user, document } = await seed();
@@ -575,6 +664,39 @@ describe('#documents.search', async () => {
expect(body.data[0].document.id).toEqual(document.id);
});
it('should return documents for a specific private collection', async () => {
const { user, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read',
});
const document = await buildDocument({
title: 'search term',
text: 'search term',
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post('/api/documents.search', {
body: {
token: user.getJwtToken(),
query: 'search term',
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].document.id).toEqual(document.id);
});
it('should return documents for a specific collection', async () => {
const { user } = await seed();
const collection = await buildCollection();
@@ -817,6 +939,7 @@ describe('#documents.pin', async () => {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.pinned).toEqual(true);
});
@@ -935,6 +1058,7 @@ describe('#documents.unpin', async () => {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.pinned).toEqual(false);
});
@@ -1036,7 +1160,7 @@ describe('#documents.create', async () => {
const newDocument = await Document.findByPk(body.data.id);
expect(res.status).toEqual(200);
expect(newDocument.parentDocumentId).toBe(null);
expect(newDocument.collection.id).toBe(collection.id);
expect(newDocument.collectionId).toBe(collection.id);
});
it('should not allow very long titles', async () => {
@@ -1126,6 +1250,38 @@ describe('#documents.update', async () => {
expect(body.data.text).toBe('Updated text');
});
it('should allow publishing document in private collection', async () => {
const { user, collection, document } = await seed();
document.publishedAt = null;
await document.save();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read_write',
});
const res = await server.post('/api/documents.update', {
body: {
token: user.getJwtToken(),
id: document.id,
title: 'Updated title',
text: 'Updated text',
lastRevision: document.revision,
publish: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.publishedAt).toBeTruthy();
expect(body.policies[0].abilities.update).toEqual(true);
});
it('should not edit archived document', async () => {
const { user, document } = await seed();
await document.archive();
@@ -1217,23 +1373,54 @@ describe('#documents.update', async () => {
expect(body.data.title).toBe('Updated title');
});
it('should require authentication', async () => {
const { document } = await seed();
const res = await server.post('/api/documents.update', {
body: { id: document.id, text: 'Updated' },
it('allows editing by read-write collection user', async () => {
const { user, document, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: 'read_write',
});
const res = await server.post('/api/documents.update', {
body: {
token: user.getJwtToken(),
id: document.id,
text: 'Changed text',
lastRevision: document.revision,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
expect(res.status).toEqual(200);
expect(body.data.text).toBe('Changed text');
});
it('should require authorization', async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post('/api/documents.update', {
body: { token: user.getJwtToken(), id: document.id, text: 'Updated' },
it('does not allow editing by read-only collection user', async () => {
const { user, document, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: 'read',
});
const res = await server.post('/api/documents.update', {
body: {
token: user.getJwtToken(),
id: document.id,
text: 'Changed text',
lastRevision: document.revision,
},
});
expect(res.status).toEqual(403);
});
@@ -1272,6 +1459,26 @@ describe('#documents.update', async () => {
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require authentication', async () => {
const { document } = await seed();
const res = await server.post('/api/documents.update', {
body: { id: document.id, text: 'Updated' },
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should require authorization', async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post('/api/documents.update', {
body: { token: user.getJwtToken(), id: document.id, text: 'Updated' },
});
expect(res.status).toEqual(403);
});
});
describe('#documents.archive', async () => {

View File

@@ -2,7 +2,7 @@
import Router from 'koa-router';
import { escapeRegExp } from 'lodash';
import { AuthenticationError, InvalidRequestError } from '../errors';
import { Authentication, Document, User, Team } from '../models';
import { Authentication, Document, User, Team, Collection } from '../models';
import { presentSlackAttachment } from '../presenters';
import * as Slack from '../slack';
const router = new Router();
@@ -85,12 +85,14 @@ router.post('hooks.interactive', async ctx => {
});
if (!document) throw new InvalidRequestError('Invalid document');
const collection = await Collection.findByPk(document.collectionId);
// respond with a public message that will be posted in the original channel
ctx.body = {
response_type: 'in_channel',
replace_original: false,
attachments: [
presentSlackAttachment(document, team, document.getSummary()),
presentSlackAttachment(document, collection, team, document.getSummary()),
],
};
});
@@ -158,6 +160,7 @@ router.post('hooks.slack', async ctx => {
attachments.push(
presentSlackAttachment(
result.document,
result.document.collection,
team,
queryIsInTitle ? undefined : result.context,
process.env.SLACK_MESSAGE_ACTIONS

View File

@@ -60,7 +60,7 @@ router.post('shares.create', auth(), async ctx => {
ctx.assertPresent(documentId, 'documentId is required');
const user = ctx.state.user;
const document = await Document.findByPk(documentId);
const document = await Document.findByPk(documentId, { userId: user.id });
const team = await Team.findByPk(user.teamId);
authorize(user, 'share', document);
authorize(user, 'share', team);

View File

@@ -1,6 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '../app';
import { CollectionUser } from '../models';
import { flushdb, seed } from '../test/support';
import { buildUser, buildShare } from '../test/factories';
@@ -110,6 +111,27 @@ describe('#shares.create', async () => {
expect(body.data.documentTitle).toBe(document.title);
});
it('should allow creating a share record for document in read-only collection', async () => {
const { user, document, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read',
});
const res = await server.post('/api/shares.create', {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documentTitle).toBe(document.title);
});
it('should allow creating a share record if link previously revoked', async () => {
const { user, document } = await seed();
const share = await buildShare({

View File

@@ -2,6 +2,7 @@
import uuid from 'uuid';
import Router from 'koa-router';
import format from 'date-fns/format';
import { Op } from '../sequelize';
import {
makePolicy,
getSignature,
@@ -20,12 +21,24 @@ const { authorize } = policy;
const router = new Router();
router.post('users.list', auth(), pagination(), async ctx => {
const { query } = ctx.body;
const user = ctx.state.user;
let where = {
teamId: user.teamId,
};
if (query) {
where = {
...where,
name: {
[Op.iLike]: `%${query}%`,
},
};
}
const users = await User.findAll({
where: {
teamId: user.teamId,
},
where,
order: [['createdAt', 'DESC']],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,

View File

@@ -11,6 +11,22 @@ beforeEach(flushdb);
afterAll(server.close);
describe('#users.list', async () => {
it('should allow filtering by user name', async () => {
const user = await buildUser({ name: 'Tester' });
const res = await server.post('/api/users.list', {
body: {
query: 'test',
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(user.id);
});
it('should return teams paginated user list', async () => {
const { admin, user } = await seed();

View File

@@ -13,7 +13,7 @@ router.post('views.list', auth(), async ctx => {
ctx.assertUuid(documentId, 'documentId is required');
const user = ctx.state.user;
const document = await Document.findByPk(documentId);
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, 'read', document);
const views = await View.findAll({
@@ -37,7 +37,7 @@ router.post('views.create', auth(), async ctx => {
ctx.assertUuid(documentId, 'documentId is required');
const user = ctx.state.user;
const document = await Document.findByPk(documentId);
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, 'read', document);
await View.increment({ documentId, userId: user.id });

View File

@@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '../app';
import { View } from '../models';
import { View, CollectionUser } from '../models';
import { flushdb, seed } from '../test/support';
import { buildUser } from '../test/factories';
@@ -25,6 +25,30 @@ describe('#views.list', async () => {
expect(body.data[0].user.name).toBe(user.name);
});
it('should return views for a document in read-only collection', async () => {
const { user, document, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read',
});
await View.increment({ documentId: document.id, userId: user.id });
const res = await server.post('/api/views.list', {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data[0].count).toBe(1);
expect(body.data[0].user.name).toBe(user.name);
});
it('should require authentication', async () => {
const { document } = await seed();
const res = await server.post('/api/views.list', {
@@ -58,6 +82,27 @@ describe('#views.create', async () => {
expect(body.success).toBe(true);
});
it('should allow creating a view record for document in read-only collection', async () => {
const { user, document, collection } = await seed();
collection.private = true;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: 'read',
});
const res = await server.post('/api/views.create', {
body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
});
it('should require authentication', async () => {
const { document } = await seed();
const res = await server.post('/api/views.create', {

View File

@@ -53,7 +53,9 @@ if (process.env.WEBSOCKETS_ENABLED === 'true') {
// allow the client to request to join rooms based on
// new collections being created.
socket.on('join', async event => {
const collection = await Collection.findByPk(event.roomId);
const collection = await Collection.scope({
method: ['withMembership', user.id],
}).findByPk(event.roomId);
if (can(user, 'read', collection)) {
socket.join(`collection-${event.roomId}`);

View File

@@ -0,0 +1,25 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('collections', 'maintainerApprovalRequired', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
await queryInterface.changeColumn('collection_users', 'permission', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'read_write',
});
await queryInterface.addIndex('collection_users', ['permission']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('collections', 'maintainerApprovalRequired');
await queryInterface.changeColumn('collection_users', 'permission', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: null,
});
await queryInterface.removeIndex('collection_users', ['permission']);
},
};

View File

@@ -21,6 +21,7 @@ const Collection = sequelize.define(
description: DataTypes.STRING,
color: DataTypes.STRING,
private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN,
type: {
type: DataTypes.STRING,
validate: { isIn: [['atlas', 'journal']] },
@@ -53,6 +54,11 @@ Collection.associate = models => {
foreignKey: 'collectionId',
onDelete: 'cascade',
});
Collection.hasMany(models.CollectionUser, {
as: 'memberships',
foreignKey: 'collectionId',
onDelete: 'cascade',
});
Collection.belongsToMany(models.User, {
as: 'users',
through: models.CollectionUser,
@@ -65,20 +71,16 @@ Collection.associate = models => {
Collection.belongsTo(models.Team, {
as: 'team',
});
Collection.addScope(
'defaultScope',
{
include: [
{
model: models.User,
as: 'users',
through: 'collection_users',
paranoid: false,
},
],
},
{ override: true }
);
Collection.addScope('withMembership', userId => ({
include: [
{
model: models.CollectionUser,
as: 'memberships',
where: { userId },
required: false,
},
],
}));
};
Collection.addHook('afterDestroy', async (model: Collection) => {

View File

@@ -6,8 +6,9 @@ const CollectionUser = sequelize.define(
{
permission: {
type: DataTypes.STRING,
defaultValue: 'read_write',
validate: {
isIn: [['read', 'read_write']],
isIn: [['read', 'read_write', 'maintainer']],
},
},
},

View File

@@ -154,25 +154,43 @@ Document.associate = models => {
Document.hasMany(models.View, {
as: 'views',
});
Document.addScope(
'defaultScope',
{
include: [
{ model: models.Collection, as: 'collection' },
{ model: models.User, as: 'createdBy', paranoid: false },
{ model: models.User, as: 'updatedBy', paranoid: false },
],
where: {
publishedAt: {
[Op.ne]: null,
},
Document.addScope('defaultScope', {
include: [
{ model: models.User, as: 'createdBy', paranoid: false },
{ model: models.User, as: 'updatedBy', paranoid: false },
],
where: {
publishedAt: {
[Op.ne]: null,
},
},
{ override: true }
);
});
Document.addScope('withCollection', userId => {
if (userId) {
return {
include: [
{
model: models.Collection,
as: 'collection',
include: [
{
model: models.CollectionUser,
as: 'memberships',
where: { userId },
required: false,
},
],
},
],
};
}
return {
include: [{ model: models.Collection, as: 'collection' }],
};
});
Document.addScope('withUnpublished', {
include: [
{ model: models.Collection, as: 'collection' },
{ model: models.User, as: 'createdBy', paranoid: false },
{ model: models.User, as: 'updatedBy', paranoid: false },
],
@@ -189,8 +207,12 @@ Document.associate = models => {
}));
};
Document.findByPk = async (id, options) => {
const scope = Document.scope('withUnpublished');
Document.findByPk = async function(id, options = {}) {
// allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope('withUnpublished', {
method: ['withCollection', options.userId],
});
if (isUUID(id)) {
return scope.findOne({
@@ -350,14 +372,18 @@ Document.searchForUser = async (
});
// Final query to get associated document data
const documents = await Document.scope({
method: ['withViews', user.id],
}).findAll({
const documents = await Document.scope(
{
method: ['withViews', user.id],
},
{
method: ['withCollection', user.id],
}
).findAll({
where: {
id: map(results, 'id'),
},
include: [
{ model: Collection, as: 'collection' },
{ model: User, as: 'createdBy', paranoid: false },
{ model: User, as: 'updatedBy', paranoid: false },
],
@@ -450,7 +476,6 @@ Document.prototype.publish = async function(options) {
this.publishedAt = new Date();
await this.save(options);
this.collection = collection;
return this;
};

View File

@@ -51,6 +51,11 @@ User.associate = models => {
});
User.hasMany(models.Document, { as: 'documents' });
User.hasMany(models.View, { as: 'views' });
User.belongsToMany(models.User, {
as: 'users',
through: 'collection_users',
onDelete: 'cascade',
});
};
// Instance methods
@@ -61,7 +66,6 @@ User.prototype.collectionIds = async function(paranoid: boolean = true) {
include: [
{
model: User,
through: 'collection_users',
as: 'users',
where: { id: this.id },
required: false,

View File

@@ -228,13 +228,22 @@ export default function Api() {
</Arguments>
</Method>
<Method method="collections.users" label="List collection members">
<Method
method="collections.memberships"
label="List collection members"
>
<Description>
This method allows you to list users with access to a private
collection.
This method allows you to list a collections memberships. This is
both a collections maintainers, and user permissions for read and
write if the collection is private
</Description>
<Arguments>
<Arguments pagination>
<Argument id="id" description="Collection ID" required />
<Argument id="query" description="Filter results by user name" />
<Argument
id="permission"
description="Filter results by permission"
/>
</Arguments>
</Method>
@@ -570,7 +579,13 @@ export default function Api() {
label="Get pinned documents for a collection"
>
<Description>Return pinned documents for a collection</Description>
<Arguments pagination />
<Arguments pagination>
<Argument
id="collectionId"
description="Collection ID"
required
/>
</Arguments>
</Method>
<Method

View File

@@ -1,6 +1,6 @@
// @flow
import invariant from 'invariant';
import policy from './policy';
import { map } from 'lodash';
import { Collection, User } from '../models';
import { AdminRequiredError } from '../errors';
@@ -8,34 +8,56 @@ const { allow } = policy;
allow(User, 'create', Collection);
allow(
User,
['read', 'publish', 'update', 'export'],
Collection,
(user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (
collection.private &&
!map(collection.users, u => u.id).includes(user.id)
) {
return false;
}
return true;
}
);
allow(User, 'delete', Collection, (user, collection) => {
allow(User, ['read', 'export'], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (
collection.private &&
!map(collection.users, u => u.id).includes(user.id)
(!collection.memberships || !collection.memberships.length)
) {
return false;
}
return true;
});
allow(User, ['publish', 'update'], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.private) {
invariant(
collection.memberships,
'membership should be preloaded, did you forget withMembership scope?'
);
if (!collection.memberships.length) return false;
return ['read_write', 'maintainer'].includes(
collection.memberships[0].permission
);
}
return true;
});
allow(User, 'delete', Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.private) {
invariant(
collection.memberships,
'membership should be preloaded, did you forget withMembership scope?'
);
if (!collection.memberships.length) return false;
if (
!['read_write', 'maintainer'].includes(
collection.memberships[0].permission
)
) {
return false;
}
}
if (user.isAdmin) return true;
if (user.id === collection.creatorId) return true;

View File

@@ -1,4 +1,5 @@
// @flow
import invariant from 'invariant';
import policy from './policy';
import { Document, Revision, User } from '../models';
@@ -6,27 +7,83 @@ const { allow, cannot } = policy;
allow(User, 'create', Document);
allow(User, ['read', 'delete'], Document, (user, document) => {
if (document.collection) {
if (cannot(user, 'read', document.collection)) return false;
allow(User, ['read', 'download'], Document, (user, document) => {
// existance of collection option is not required here to account for share tokens
if (document.collection && cannot(user, 'read', document.collection)) {
return false;
}
return user.teamId === document.teamId;
});
allow(User, ['update', 'move', 'share'], Document, (user, document) => {
if (document.collection) {
if (cannot(user, 'read', document.collection)) return false;
allow(User, ['share'], Document, (user, document) => {
if (document.archivedAt) return false;
// existance of collection option is not required here to account for share tokens
if (document.collection && cannot(user, 'read', document.collection)) {
return false;
}
return user.teamId === document.teamId;
});
allow(User, ['star', 'unstar'], Document, (user, document) => {
if (document.archivedAt) return false;
if (!document.publishedAt) return false;
invariant(
document.collection,
'collection is missing, did you forget to include in the query scope?'
);
if (cannot(user, 'read', document.collection)) return false;
return user.teamId === document.teamId;
});
allow(User, 'update', Document, (user, document) => {
invariant(
document.collection,
'collection is missing, did you forget to include in the query scope?'
);
if (cannot(user, 'update', document.collection)) return false;
if (document.archivedAt) return false;
return user.teamId === document.teamId;
});
allow(User, ['move', 'pin', 'unpin'], Document, (user, document) => {
invariant(
document.collection,
'collection is missing, did you forget to include in the query scope?'
);
if (cannot(user, 'update', document.collection)) return false;
if (document.archivedAt) return false;
if (!document.publishedAt) return false;
return user.teamId === document.teamId;
});
allow(User, 'delete', Document, (user, document) => {
// unpublished drafts can always be deleted
if (!document.publishedAt && user.teamId === document.teamId) {
return true;
}
// allow deleting document without a collection
if (document.collection && cannot(user, 'update', document.collection))
return false;
if (document.archivedAt) return false;
return user.teamId === document.teamId;
});
allow(User, 'archive', Document, (user, document) => {
if (document.collection) {
if (cannot(user, 'read', document.collection)) return false;
}
invariant(
document.collection,
'collection is missing, did you forget to include in the query scope?'
);
if (cannot(user, 'update', document.collection)) return false;
if (!document.publishedAt) return false;
if (document.archivedAt) return false;
@@ -34,9 +91,12 @@ allow(User, 'archive', Document, (user, document) => {
});
allow(User, 'unarchive', Document, (user, document) => {
if (document.collection) {
if (cannot(user, 'read', document.collection)) return false;
}
invariant(
document.collection,
'collection is missing, did you forget to include in the query scope?'
);
if (cannot(user, 'update', document.collection)) return false;
if (!document.archivedAt) return false;
return user.teamId === document.teamId;

View File

@@ -9,6 +9,7 @@ import presentApiKey from './apiKey';
import presentShare from './share';
import presentTeam from './team';
import presentIntegration from './integration';
import presentMembership from './membership';
import presentNotificationSetting from './notificationSetting';
import presentSlackAttachment from './slackAttachment';
import presentPolicies from './policy';
@@ -24,6 +25,7 @@ export {
presentShare,
presentTeam,
presentIntegration,
presentMembership,
presentNotificationSetting,
presentSlackAttachment,
presentPolicies,

View File

@@ -0,0 +1,18 @@
// @flow
import { CollectionUser } from '../models';
type Membership = {
id: string,
userId: string,
collectionId: string,
permission: string,
};
export default (membership: CollectionUser): Membership => {
return {
id: `${membership.userId}-${membership.collectionId}`,
userId: membership.userId,
collectionId: membership.collectionId,
permission: membership.permission,
};
};

View File

@@ -1,5 +1,5 @@
// @flow
import { Document, Team } from '../models';
import { Document, Collection, Team } from '../models';
type Action = {
type: string,
@@ -10,6 +10,7 @@ type Action = {
export default function present(
document: Document,
collection: Collection,
team: Team,
context?: string,
actions?: Action[]
@@ -21,10 +22,10 @@ export default function present(
: document.getSummary();
return {
color: document.collection.color,
color: collection.color,
title: document.title,
title_link: `${team.url}${document.url}`,
footer: document.collection.name,
footer: collection.name,
callback_id: document.id,
text,
ts: document.getTimestamp(),

View File

@@ -1,7 +1,6 @@
// @flow
import type { Event } from '../events';
import { Document, Collection } from '../models';
import { presentDocument, presentCollection } from '../presenters';
import { socketio } from '../';
export default class Websockets {
@@ -12,34 +11,97 @@ export default class Websockets {
case 'documents.publish':
case 'documents.restore':
case 'documents.archive':
case 'documents.unarchive':
case 'documents.pin':
case 'documents.unpin':
case 'documents.update':
case 'documents.delete': {
case 'documents.unarchive': {
const document = await Document.findByPk(event.documentId, {
paranoid: false,
});
const documents = [await presentDocument(document)];
const collections = [await presentCollection(document.collection)];
return socketio
.to(`collection-${document.collectionId}`)
.emit('entities', {
event: event.name,
documents,
collections,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
collectionIds: [
{
id: document.collectionId,
},
],
});
}
case 'documents.delete': {
const document = await Document.findByPk(event.documentId, {
paranoid: false,
});
if (!document.publishedAt) {
return socketio.to(`user-${document.createdById}`).emit('entities', {
event: event.name,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
});
}
return socketio
.to(`collection-${document.collectionId}`)
.emit('entities', {
event: event.name,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
collectionIds: [
{
id: document.collectionId,
},
],
});
}
case 'documents.pin':
case 'documents.unpin':
case 'documents.update': {
const document = await Document.findByPk(event.documentId, {
paranoid: false,
});
return socketio
.to(`collection-${document.collectionId}`)
.emit('entities', {
event: event.name,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
});
}
case 'documents.create': {
const document = await Document.findByPk(event.documentId);
const documents = [await presentDocument(document)];
const collections = [await presentCollection(document.collection)];
return socketio.to(`user-${event.actorId}`).emit('entities', {
event: event.name,
documents,
collections,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
collectionIds: [
{
id: document.collectionId,
},
],
});
}
case 'documents.star':
@@ -55,24 +117,21 @@ export default class Websockets {
},
paranoid: false,
});
const collections = await Collection.findAll({
where: {
id: event.data.collectionIds,
},
paranoid: false,
});
documents.forEach(async document => {
const documents = [await presentDocument(document)];
documents.forEach(document => {
socketio.to(`collection-${document.collectionId}`).emit('entities', {
event: event.name,
documents,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
});
});
collections.forEach(async collection => {
const collections = [await presentCollection(collection)];
socketio.to(`collection-${collection.id}`).emit('entities', {
event.data.collectionIds.forEach(collectionId => {
socketio.to(`collection-${collectionId}`).emit('entities', {
event: event.name,
collections,
collectionIds: [{ id: collectionId }],
});
});
return;
@@ -81,7 +140,6 @@ export default class Websockets {
const collection = await Collection.findByPk(event.collectionId, {
paranoid: false,
});
const collections = [await presentCollection(collection)];
socketio
.to(
@@ -91,7 +149,12 @@ export default class Websockets {
)
.emit('entities', {
event: event.name,
collections,
collectionIds: [
{
id: collection.id,
updatedAt: collection.updatedAt,
},
],
});
return socketio
.to(
@@ -109,24 +172,53 @@ export default class Websockets {
const collection = await Collection.findByPk(event.collectionId, {
paranoid: false,
});
const collections = [await presentCollection(collection)];
return socketio.to(`collection-${collection.id}`).emit('entities', {
return socketio.to(`team-${collection.teamId}`).emit('entities', {
event: event.name,
collections,
collectionIds: [
{
id: collection.id,
updatedAt: collection.updatedAt,
},
],
});
}
case 'collections.add_user':
case 'collections.add_user': {
// the user being added isn't yet in the websocket channel for the collection
// so they need to be notified separately
socketio.to(`user-${event.userId}`).emit(event.name, {
event: event.name,
userId: event.userId,
collectionId: event.collectionId,
});
// let everyone with access to the collection know a user was added
socketio.to(`collection-${event.collectionId}`).emit(event.name, {
event: event.name,
userId: event.userId,
collectionId: event.collectionId,
});
// tell any user clients to connect to the websocket channel for the collection
return socketio.to(`user-${event.userId}`).emit('join', {
event: event.name,
roomId: event.collectionId,
});
case 'collections.remove_user':
}
case 'collections.remove_user': {
// let everyone with access to the collection know a user was removed
socketio.to(`collection-${event.collectionId}`).emit(event.name, {
event: event.name,
userId: event.userId,
collectionId: event.collectionId,
});
// tell any user clients to disconnect from the websocket channel for the collection
return socketio.to(`user-${event.userId}`).emit('leave', {
event: event.name,
roomId: event.collectionId,
});
}
default:
}
}