feat: Collection admins (#5273

* Split permissions for reading documents from updating collection

* fix: Admins should have collection read permission, tests

* tsc

* Add admin option to permission selector

* Combine publish and create permissions, update -> createDocuments where appropriate

* Plural -> singular

* wip

* Quick version of collection structure loading, will revisit

* Remove documentIds method

* stash

* fixing tests to account for admin creation

* Add self-hosted migration

* fix: Allow groups to have admin permission

* Prefetch collection documents

* fix: Document explorer (move/publish) not working with async documents

* fix: Cannot re-parent document to collection by drag and drop

* fix: Cannot drag to import into collection item without admin permission

* Remove unused isEditor getter
This commit is contained in:
Tom Moor
2023-04-30 09:38:47 -04:00
committed by GitHub
parent 2942e9c78e
commit d8b4fef554
44 changed files with 799 additions and 535 deletions

View File

@@ -396,18 +396,18 @@ describe("#collections.export_all", () => {
describe("#collections.add_user", () => {
it("should add user to collection", async () => {
const user = await buildUser();
const admin = await buildAdmin();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
teamId: admin.teamId,
userId: admin.id,
permission: null,
});
const anotherUser = await buildUser({
teamId: user.teamId,
teamId: admin.teamId,
});
const res = await server.post("/api/collections.add_user", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
userId: anotherUser.id,
},
@@ -616,25 +616,25 @@ describe("#collections.remove_group", () => {
describe("#collections.remove_user", () => {
it("should remove user from collection", async () => {
const user = await buildUser();
const admin = await buildAdmin();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
teamId: admin.teamId,
userId: admin.id,
permission: null,
});
const anotherUser = await buildUser({
teamId: user.teamId,
teamId: admin.teamId,
});
await server.post("/api/collections.add_user", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
userId: anotherUser.id,
},
});
const res = await server.post("/api/collections.remove_user", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
userId: anotherUser.id,
},
@@ -839,12 +839,7 @@ describe("#collections.memberships", () => {
const { collection, user } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.memberships", {
body: {
token: user.getJwtToken(),
@@ -857,7 +852,7 @@ describe("#collections.memberships", () => {
expect(body.data.users[0].id).toEqual(user.id);
expect(body.data.memberships.length).toEqual(1);
expect(body.data.memberships[0].permission).toEqual(
CollectionPermission.ReadWrite
CollectionPermission.Admin
);
});
@@ -866,12 +861,6 @@ describe("#collections.memberships", () => {
const user2 = await buildUser({
name: "Won't find",
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
await CollectionUser.create({
createdById: user2.id,
collectionId: collection.id,
@@ -957,6 +946,12 @@ describe("#collections.info", () => {
const { user, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.destroy({
where: {
collectionId: collection.id,
userId: user.id,
},
});
const res = await server.post("/api/collections.info", {
body: {
token: user.getJwtToken(),
@@ -1181,10 +1176,10 @@ describe("#collections.update", () => {
});
it("allows editing non-private collection", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
name: "Test",
},
@@ -1196,14 +1191,14 @@ describe("#collections.update", () => {
});
it("allows editing sort", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
const sort = {
field: "index",
direction: "desc",
};
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
sort,
},
@@ -1215,10 +1210,10 @@ describe("#collections.update", () => {
});
it("allows editing individual fields", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
permission: null,
},
@@ -1230,10 +1225,10 @@ describe("#collections.update", () => {
});
it("allows editing from non-private to private collection, and trims whitespace", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
permission: null,
name: " Test ",
@@ -1249,18 +1244,18 @@ describe("#collections.update", () => {
});
it("allows editing from private to non-private collection", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
userId: admin.id,
createdById: admin.id,
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
permission: CollectionPermission.ReadWrite,
name: "Test",
@@ -1276,18 +1271,18 @@ describe("#collections.update", () => {
});
it("allows editing by read-write collection user", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
userId: admin.id,
createdById: admin.id,
permission: CollectionPermission.ReadWrite,
});
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
name: "Test",
},
@@ -1298,7 +1293,7 @@ describe("#collections.update", () => {
expect(body.policies.length).toBe(1);
});
it("allows editing by read-write collection group user", async () => {
it("allows editing by admin collection group user", async () => {
const user = await buildUser();
const collection = await buildCollection({
permission: null,
@@ -1314,7 +1309,7 @@ describe("#collections.update", () => {
});
await collection.$add("group", group, {
through: {
permission: CollectionPermission.ReadWrite,
permission: CollectionPermission.Admin,
createdById: user.id,
},
});
@@ -1335,12 +1330,18 @@ describe("#collections.update", () => {
const { user, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: CollectionPermission.Read,
});
await CollectionUser.update(
{
createdById: user.id,
permission: CollectionPermission.Read,
},
{
where: {
collectionId: collection.id,
userId: user.id,
},
}
);
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
@@ -1352,14 +1353,14 @@ describe("#collections.update", () => {
});
it("does not allow setting unknown sort fields", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
const sort = {
field: "blah",
direction: "desc",
};
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
sort,
},
@@ -1368,14 +1369,14 @@ describe("#collections.update", () => {
});
it("does not allow setting unknown sort directions", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
const sort = {
field: "title",
direction: "blah",
};
const res = await server.post("/api/collections.update", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
sort,
},
@@ -1405,10 +1406,10 @@ describe("#collections.delete", () => {
});
it("should not allow deleting last collection", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
const res = await server.post("/api/collections.delete", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
},
});
@@ -1416,15 +1417,15 @@ describe("#collections.delete", () => {
});
it("should delete collection", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
// to ensure it isn't the last collection
await buildCollection({
teamId: user.teamId,
createdById: user.id,
teamId: admin.teamId,
createdById: admin.id,
});
const res = await server.post("/api/collections.delete", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
},
});
@@ -1434,11 +1435,11 @@ describe("#collections.delete", () => {
});
it("should delete published documents", async () => {
const { user, collection } = await seed();
const { admin, collection } = await seed();
// to ensure it isn't the last collection
await buildCollection({
teamId: user.teamId,
createdById: user.id,
teamId: admin.teamId,
createdById: admin.id,
});
// archived document should not be deleted
await buildDocument({
@@ -1447,7 +1448,7 @@ describe("#collections.delete", () => {
});
const res = await server.post("/api/collections.delete", {
body: {
token: user.getJwtToken(),
token: admin.getJwtToken(),
id: collection.id,
},
});
@@ -1463,7 +1464,7 @@ describe("#collections.delete", () => {
).toEqual(1);
});
it("allows deleting by read-write collection group user", async () => {
it("allows deleting by admin collection group user", async () => {
const user = await buildUser();
const collection = await buildCollection({
permission: null,
@@ -1482,7 +1483,7 @@ describe("#collections.delete", () => {
});
await collection.$add("group", group, {
through: {
permission: CollectionPermission.ReadWrite,
permission: CollectionPermission.Admin,
createdById: user.id,
},
});

View File

@@ -148,6 +148,21 @@ router.post("collections.info", auth(), async (ctx: APIContext) => {
};
});
router.post("collections.documents", auth(), async (ctx: APIContext) => {
const { id } = ctx.request.body;
assertPresent(id, "id is required");
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "readDocument", collection);
ctx.body = {
data: collection.documentStructure || [],
};
});
router.post(
"collections.import",
rateLimiter(RateLimiterStrategy.TenPerHour),
@@ -641,7 +656,7 @@ router.post("collections.update", auth(), async (ctx: APIContext) => {
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
// current user has an admin membership so that at least they can manage it.
if (
permission !== CollectionPermission.ReadWrite &&
collection.permission === CollectionPermission.ReadWrite
@@ -652,7 +667,7 @@ router.post("collections.update", auth(), async (ctx: APIContext) => {
userId: user.id,
},
defaults: {
permission: CollectionPermission.ReadWrite,
permission: CollectionPermission.Admin,
createdById: user.id,
},
});

View File

@@ -695,6 +695,12 @@ describe("#documents.list", () => {
const { user, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
@@ -766,12 +772,18 @@ describe("#documents.list", () => {
const { user, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.Read,
});
await CollectionUser.update(
{
userId: user.id,
permission: CollectionPermission.Read,
},
{
where: {
createdById: user.id,
collectionId: collection.id,
},
}
);
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
@@ -891,6 +903,12 @@ describe("#documents.drafts", () => {
await document.save();
collection.permission = null;
await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.drafts", {
body: {
token: user.getJwtToken(),
@@ -1792,6 +1810,12 @@ describe("#documents.viewed", () => {
});
collection.permission = null;
await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.viewed", {
body: {
token: user.getJwtToken(),
@@ -2565,12 +2589,18 @@ describe("#documents.update", () => {
await document.save();
collection.permission = null;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
await CollectionUser.update(
{
userId: user.id,
permission: CollectionPermission.ReadWrite,
},
{
where: {
createdById: user.id,
collectionId: collection.id,
},
}
);
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
@@ -2634,18 +2664,24 @@ describe("#documents.update", () => {
});
it("allows editing by read-write collection user", async () => {
const { admin, document, collection } = await seed();
const { user, document, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: admin.id,
createdById: admin.id,
permission: CollectionPermission.ReadWrite,
});
await CollectionUser.update(
{
createdById: user.id,
permission: CollectionPermission.ReadWrite,
},
{
where: {
collectionId: collection.id,
userId: user.id,
},
}
);
const res = await server.post("/api/documents.update", {
body: {
token: admin.getJwtToken(),
token: user.getJwtToken(),
id: document.id,
text: "Changed text",
},
@@ -2653,19 +2689,25 @@ describe("#documents.update", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.text).toBe("Changed text");
expect(body.data.updatedBy.id).toBe(admin.id);
expect(body.data.updatedBy.id).toBe(user.id);
});
it("does not allow editing by read-only collection user", async () => {
const { user, document, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: CollectionPermission.Read,
});
await CollectionUser.update(
{
createdById: user.id,
permission: CollectionPermission.Read,
},
{
where: {
collectionId: collection.id,
userId: user.id,
},
}
);
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
@@ -2680,6 +2722,12 @@ describe("#documents.update", () => {
const { user, document, collection } = await seed();
collection.permission = CollectionPermission.Read;
await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
@@ -2831,10 +2879,6 @@ describe("#documents.update", () => {
expect(body.data.document.collectionId).toBe(collection.id);
expect(body.data.document.title).toBe("Updated title");
expect(body.data.document.text).toBe("Updated text");
expect(body.data.collection.icon).toBe(collection.icon);
expect(body.data.collection.documents.length).toBe(
collection.documentStructure!.length + 1
);
});
});
});

View File

@@ -98,7 +98,7 @@ router.post(
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
@@ -342,7 +342,7 @@ router.post(
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
authorize(user, "readDocument", collection);
}
const collectionIds = collectionId
@@ -599,7 +599,7 @@ router.post(
}
if (document.collection) {
authorize(user, "update", collection);
authorize(user, "updateDocument", collection);
}
if (document.deletedAt) {
@@ -686,7 +686,7 @@ router.post(
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
authorize(user, "readDocument", collection);
}
if (userId) {
@@ -780,7 +780,7 @@ router.post(
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
authorize(user, "readDocument", collection);
}
let collaboratorIds = undefined;
@@ -921,7 +921,7 @@ router.post(
method: ["withMembership", user.id],
}).findByPk(collectionId!);
}
authorize(user, "publish", collection);
authorize(user, "createDocument", collection);
}
collection = await sequelize.transaction(async (transaction) => {
@@ -982,7 +982,7 @@ router.post(
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "update", collection);
authorize(user, "updateDocument", collection);
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
@@ -1205,7 +1205,7 @@ router.post(
teamId: user.teamId,
},
});
authorize(user, "publish", collection);
authorize(user, "createDocument", collection);
let parentDocument;
if (parentDocumentId) {
@@ -1282,7 +1282,7 @@ router.post(
teamId: user.teamId,
},
});
authorize(user, "publish", collection);
authorize(user, "createDocument", collection);
}
let parentDocument;

View File

@@ -8,7 +8,9 @@ const DocumentsSortParamsSchema = z.object({
/** Specifies the attributes by which documents will be sorted in the list */
sort: z
.string()
.refine((val) => ["createdAt", "updatedAt", "index", "title"].includes(val))
.refine((val) =>
["createdAt", "updatedAt", "publishedAt", "index", "title"].includes(val)
)
.default("updatedAt"),
/** Specifies the sort order with respect to sort field */

View File

@@ -1,4 +1,4 @@
import { Revision } from "@server/models";
import { CollectionUser, Revision } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support";
@@ -141,6 +141,12 @@ describe("#revisions.list", () => {
await Revision.createFromDocument(document);
collection.permission = null;
await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/revisions.list", {
body: {
token: user.getJwtToken(),

View File

@@ -156,12 +156,18 @@ describe("#shares.create", () => {
const { user, document, collection } = await seed();
collection.permission = null;
await collection.save();
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.Read,
});
await CollectionUser.update(
{
userId: user.id,
permission: CollectionPermission.Read,
},
{
where: {
createdById: user.id,
collectionId: collection.id,
},
}
);
const res = await server.post("/api/shares.create", {
body: {
token: user.getJwtToken(),