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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
25
server/migrations/20190811231511-maintainers.js
Normal file
25
server/migrations/20190811231511-maintainers.js
Normal 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']);
|
||||
},
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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']],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
18
server/presenters/membership.js
Normal file
18
server/presenters/membership.js
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user