feat: Allow viewers to be upgraded to editors on individual collections (#4023)

* Improve types

* More types, fix default permission for viewers added to collection

* fix change of default role for CollectionGroup

* Restore policy

* test

* tests
This commit is contained in:
Tom Moor
2022-08-31 08:12:27 +02:00
committed by GitHub
parent b8115ae3ce
commit 212985e18f
42 changed files with 537 additions and 435 deletions

View File

@@ -130,12 +130,3 @@ Object {
"status": 401,
}
`;
exports[`#collections.users should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -1,3 +1,4 @@
import { CollectionPermission } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { Document, CollectionUser, CollectionGroup } from "@server/models";
import {
@@ -101,7 +102,7 @@ describe("#collections.list", () => {
});
await collection.$add("group", group, {
through: {
permission: "read",
permission: CollectionPermission.Read,
createdById: user.id,
},
});
@@ -293,7 +294,7 @@ describe("#collections.export", () => {
createdById: admin.id,
collectionId: collection.id,
userId: admin.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.export", {
body: {
@@ -320,7 +321,7 @@ describe("#collections.export", () => {
});
await collection.$add("group", group, {
through: {
permission: "read_write",
permission: CollectionPermission.ReadWrite,
createdById: admin.id,
},
});
@@ -671,48 +672,6 @@ describe("#collections.remove_user", () => {
});
});
describe("#collections.users", () => {
it("should return users in private collection", async () => {
const { collection, user } = await seed();
collection.permission = null;
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 () => {
const res = await server.post("/api/collections.users");
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.users", {
body: {
token: user.getJwtToken(),
id: collection.id,
},
});
expect(res.status).toEqual(403);
});
});
describe("#collections.group_memberships", () => {
it("should return groups in private collection", async () => {
const user = await buildUser();
@@ -727,13 +686,13 @@ describe("#collections.group_memberships", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
await CollectionGroup.create({
createdById: user.id,
collectionId: collection.id,
groupId: group.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.group_memberships", {
body: {
@@ -747,7 +706,7 @@ describe("#collections.group_memberships", () => {
expect(body.data.groups[0].id).toEqual(group.id);
expect(body.data.collectionGroupMemberships.length).toEqual(1);
expect(body.data.collectionGroupMemberships[0].permission).toEqual(
"read_write"
CollectionPermission.ReadWrite
);
});
@@ -769,19 +728,19 @@ describe("#collections.group_memberships", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
await CollectionGroup.create({
createdById: user.id,
collectionId: collection.id,
groupId: group.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
await CollectionGroup.create({
createdById: user.id,
collectionId: collection.id,
groupId: group2.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.group_memberships", {
body: {
@@ -812,25 +771,25 @@ describe("#collections.group_memberships", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
await CollectionGroup.create({
createdById: user.id,
collectionId: collection.id,
groupId: group.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
await CollectionGroup.create({
createdById: user.id,
collectionId: collection.id,
groupId: group2.id,
permission: "maintainer",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/collections.group_memberships", {
body: {
token: user.getJwtToken(),
id: collection.id,
permission: "maintainer",
permission: CollectionPermission.Read,
},
});
const body = await res.json();
@@ -871,7 +830,7 @@ describe("#collections.memberships", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.memberships", {
body: {
@@ -884,7 +843,9 @@ describe("#collections.memberships", () => {
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");
expect(body.data.memberships[0].permission).toEqual(
CollectionPermission.ReadWrite
);
});
it("should allow filtering members in collection by name", async () => {
@@ -896,13 +857,13 @@ describe("#collections.memberships", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
await CollectionUser.create({
createdById: user2.id,
collectionId: collection.id,
userId: user2.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.memberships", {
body: {
@@ -924,19 +885,19 @@ describe("#collections.memberships", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
await CollectionUser.create({
createdById: user2.id,
collectionId: collection.id,
userId: user2.id,
permission: "maintainer",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/collections.memberships", {
body: {
token: user.getJwtToken(),
id: collection.id,
permission: "maintainer",
permission: CollectionPermission.Read,
},
});
const body = await res.json();
@@ -1000,7 +961,7 @@ describe("#collections.info", () => {
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/collections.info", {
body: {
@@ -1282,20 +1243,20 @@ describe("#collections.update", () => {
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
id: collection.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
name: "Test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("Test");
expect(body.data.permission).toBe("read_write");
expect(body.data.permission).toBe(CollectionPermission.ReadWrite);
// ensure we return with a write level policy
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.update).toBe(true);
@@ -1309,7 +1270,7 @@ describe("#collections.update", () => {
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.update", {
body: {
@@ -1340,7 +1301,7 @@ describe("#collections.update", () => {
});
await collection.$add("group", group, {
through: {
permission: "read_write",
permission: CollectionPermission.ReadWrite,
createdById: user.id,
},
});
@@ -1365,7 +1326,7 @@ describe("#collections.update", () => {
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/collections.update", {
body: {
@@ -1508,7 +1469,7 @@ describe("#collections.delete", () => {
});
await collection.$add("group", group, {
through: {
permission: "read_write",
permission: CollectionPermission.ReadWrite,
createdById: user.id,
},
});

View File

@@ -3,6 +3,7 @@ import invariant from "invariant";
import Router from "koa-router";
import { Sequelize, Op, WhereOptions } from "sequelize";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { RateLimiterStrategy } from "@server/RateLimiter";
import collectionExporter from "@server/commands/collectionExporter";
@@ -45,6 +46,7 @@ import {
assertPresent,
assertHexColor,
assertIndexCharacters,
assertCollectionPermission,
} from "@server/validation";
import pagination from "./middlewares/pagination";
@@ -199,9 +201,10 @@ router.post(
);
router.post("collections.add_group", auth(), async (ctx) => {
const { id, groupId, permission = "read_write" } = ctx.body;
const { id, groupId, permission = CollectionPermission.ReadWrite } = ctx.body;
assertUuid(id, "id is required");
assertUuid(groupId, "groupId is required");
assertCollectionPermission(permission);
const collection = await Collection.scope({
method: ["withMembership", ctx.state.user.id],
@@ -340,7 +343,7 @@ router.post(
);
router.post("collections.add_user", auth(), async (ctx) => {
const { id, userId, permission = "read_write" } = ctx.body;
const { id, userId, permission } = ctx.body;
assertUuid(id, "id is required");
assertUuid(userId, "userId is required");
@@ -359,11 +362,15 @@ router.post("collections.add_user", auth(), async (ctx) => {
},
});
if (permission) {
assertCollectionPermission(permission);
}
if (!membership) {
membership = await CollectionUser.create({
collectionId: id,
userId,
permission,
permission: permission || user.defaultCollectionPermission,
createdById: ctx.state.user.id,
});
} else if (permission) {
@@ -422,24 +429,6 @@ 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;
assertUuid(id, "id is required");
const { user } = ctx.state;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "read", collection);
const users = await collection.$get("users");
ctx.body = {
data: users.map((user) => presentUser(user)),
};
});
router.post("collections.memberships", auth(), pagination(), async (ctx) => {
const { id, query, permission } = ctx.body;
assertUuid(id, "id is required");
@@ -464,6 +453,7 @@ router.post("collections.memberships", auth(), pagination(), async (ctx) => {
}
if (permission) {
assertCollectionPermission(permission);
where = { ...where, permission };
}
@@ -575,16 +565,19 @@ router.post("collections.update", auth(), async (ctx) => {
}).findByPk(id);
authorize(user, "update", collection);
// we're making this collection have no default access, ensure that the current
// user has a read-write membership so that at least they can edit it
if (permission !== "read_write" && collection.permission === "read_write") {
// we're making this collection have no default access, ensure that the
// current user has a read-write membership so that at least they can edit it
if (
permission !== CollectionPermission.ReadWrite &&
collection.permission === CollectionPermission.ReadWrite
) {
await CollectionUser.findOrCreate({
where: {
collectionId: collection.id,
userId: user.id,
},
defaults: {
permission: "read_write",
permission: CollectionPermission.ReadWrite,
createdById: user.id,
},
});
@@ -610,12 +603,9 @@ router.post("collections.update", auth(), async (ctx) => {
}
if (permission !== undefined) {
// frontend sends empty string
assertIn(
permission,
["read_write", "read", "", null],
"Invalid permission"
);
if (permission) {
assertCollectionPermission(permission);
}
privacyChanged = permission !== collection.permission;
collection.permission = permission ? permission : null;
}

View File

@@ -1,3 +1,4 @@
import { CollectionPermission } from "@shared/types";
import {
Document,
View,
@@ -800,7 +801,7 @@ describe("#documents.list", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/documents.list", {
body: {
@@ -1244,7 +1245,7 @@ describe("#documents.search", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const document = await buildDocument({
title: "search term",
@@ -2005,7 +2006,7 @@ describe("#documents.update", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/documents.update", {
body: {
@@ -2094,7 +2095,7 @@ describe("#documents.update", () => {
collectionId: collection.id,
userId: admin.id,
createdById: admin.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/documents.update", {
body: {
@@ -2118,7 +2119,7 @@ describe("#documents.update", () => {
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/documents.update", {
body: {
@@ -2133,7 +2134,7 @@ describe("#documents.update", () => {
it("does not allow editing in read-only collection", async () => {
const { user, document, collection } = await seed();
collection.permission = "read";
collection.permission = CollectionPermission.Read;
await collection.save();
const res = await server.post("/api/documents.update", {
body: {

View File

@@ -326,76 +326,71 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
};
});
router.post(
"documents.drafts",
auth({ member: true }),
pagination(),
async (ctx) => {
let { direction } = ctx.body;
const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body;
router.post("documents.drafts", auth(), pagination(), async (ctx) => {
let { direction } = ctx.body;
const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body;
assertSort(sort, Document);
if (direction !== "ASC") {
direction = "DESC";
}
const { user } = ctx.state;
if (collectionId) {
assertUuid(collectionId, "collectionId must be a UUID");
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
}
const collectionIds = collectionId
? [collectionId]
: await user.collectionIds();
const where: WhereOptions<Document> = {
createdById: user.id,
collectionId: collectionIds,
publishedAt: {
[Op.is]: null,
},
};
if (dateFilter) {
assertIn(
dateFilter,
["day", "week", "month", "year"],
"dateFilter must be one of day,week,month,year"
);
where.updatedAt = {
[Op.gte]: subtractDate(new Date(), dateFilter),
};
} else {
delete where.updatedAt;
}
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
"defaultScope",
collectionScope,
]).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
assertSort(sort, Document);
if (direction !== "ASC") {
direction = "DESC";
}
);
const { user } = ctx.state;
if (collectionId) {
assertUuid(collectionId, "collectionId must be a UUID");
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
}
const collectionIds = collectionId
? [collectionId]
: await user.collectionIds();
const where: WhereOptions<Document> = {
createdById: user.id,
collectionId: collectionIds,
publishedAt: {
[Op.is]: null,
},
};
if (dateFilter) {
assertIn(
dateFilter,
["day", "week", "month", "year"],
"dateFilter must be one of day,week,month,year"
);
where.updatedAt = {
[Op.gte]: subtractDate(new Date(), dateFilter),
};
} else {
delete where.updatedAt;
}
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
"defaultScope",
collectionScope,
]).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
});
router.post(
"documents.info",
@@ -777,7 +772,7 @@ router.post("documents.templatize", auth({ member: true }), async (ctx) => {
};
});
router.post("documents.update", auth({ member: true }), async (ctx) => {
router.post("documents.update", auth(), async (ctx) => {
const {
id,
title,
@@ -837,7 +832,7 @@ router.post("documents.update", auth({ member: true }), async (ctx) => {
};
});
router.post("documents.move", auth({ member: true }), async (ctx) => {
router.post("documents.move", auth(), async (ctx) => {
const { id, collectionId, parentDocumentId, index } = ctx.body;
assertUuid(id, "id must be a uuid");
assertUuid(collectionId, "collectionId must be a uuid");
@@ -903,7 +898,7 @@ router.post("documents.move", auth({ member: true }), async (ctx) => {
};
});
router.post("documents.archive", auth({ member: true }), async (ctx) => {
router.post("documents.archive", auth(), async (ctx) => {
const { id } = ctx.body;
assertPresent(id, "id is required");
const { user } = ctx.state;
@@ -932,7 +927,7 @@ router.post("documents.archive", auth({ member: true }), async (ctx) => {
};
});
router.post("documents.delete", auth({ member: true }), async (ctx) => {
router.post("documents.delete", auth(), async (ctx) => {
const { id, permanent } = ctx.body;
assertPresent(id, "id is required");
const { user } = ctx.state;
@@ -993,7 +988,7 @@ router.post("documents.delete", auth({ member: true }), async (ctx) => {
};
});
router.post("documents.unpublish", auth({ member: true }), async (ctx) => {
router.post("documents.unpublish", auth(), async (ctx) => {
const { id } = ctx.body;
assertPresent(id, "id is required");
const { user } = ctx.state;
@@ -1027,7 +1022,7 @@ router.post("documents.unpublish", auth({ member: true }), async (ctx) => {
};
});
router.post("documents.import", auth({ member: true }), async (ctx) => {
router.post("documents.import", auth(), async (ctx) => {
const { publish, collectionId, parentDocumentId, index } = ctx.body;
if (!ctx.is("multipart/form-data")) {
@@ -1052,7 +1047,6 @@ router.post("documents.import", auth({ member: true }), async (ctx) => {
assertPositiveInteger(index, "index must be an integer (>=0)");
}
const { user } = ctx.state;
authorize(user, "createDocument", user.team);
const collection = await Collection.scope({
method: ["withMembership", user.id],
@@ -1110,7 +1104,7 @@ router.post("documents.import", auth({ member: true }), async (ctx) => {
});
});
router.post("documents.create", auth({ member: true }), async (ctx) => {
router.post("documents.create", auth(), async (ctx) => {
const {
title = "",
text = "",
@@ -1132,7 +1126,6 @@ router.post("documents.create", auth({ member: true }), async (ctx) => {
assertPositiveInteger(index, "index must be an integer (>=0)");
}
const { user } = ctx.state;
authorize(user, "createDocument", user.team);
const collection = await Collection.scope({
method: ["withMembership", user.id],

View File

@@ -1,3 +1,4 @@
import { CollectionPermission } from "@shared/types";
import { CollectionUser } from "@server/models";
import {
buildUser,
@@ -164,7 +165,7 @@ describe("#shares.create", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/shares.create", {
body: {

View File

@@ -1,3 +1,4 @@
import { CollectionPermission } from "@shared/types";
import { View, CollectionUser } from "@server/models";
import { buildUser } from "@server/test/factories";
import { seed, getTestDatabase, getTestServer } from "@server/test/support";
@@ -56,7 +57,7 @@ describe("#views.list", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
await View.incrementOrCreate({
documentId: document.id,
@@ -121,7 +122,7 @@ describe("#views.create", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const res = await server.post("/api/views.create", {
body: {