Merge branch 'main' into feat/mass-import

This commit is contained in:
Tom Moor
2021-02-09 20:46:57 -08:00
committed by GitHub
42 changed files with 663 additions and 295 deletions

View File

@@ -35,6 +35,7 @@ router.post("collections.create", auth(), async (ctx) => {
name,
color,
description,
sharing,
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
@@ -56,6 +57,7 @@ router.post("collections.create", auth(), async (ctx) => {
teamId: user.teamId,
createdById: user.id,
private: isPrivate,
sharing,
sort,
});
@@ -491,7 +493,7 @@ router.post("collections.export_all", auth(), async (ctx) => {
});
router.post("collections.update", auth(), async (ctx) => {
let { id, name, description, icon, color, sort } = ctx.body;
let { id, name, description, icon, color, sort, sharing } = ctx.body;
const isPrivate = ctx.body.private;
if (color) {
@@ -537,6 +539,9 @@ router.post("collections.update", auth(), async (ctx) => {
if (isPrivate !== undefined) {
collection.private = isPrivate;
}
if (sharing !== undefined) {
collection.sharing = sharing;
}
if (sort !== undefined) {
collection.sort = sort;
}

View File

@@ -897,6 +897,18 @@ describe("#collections.create", () => {
expect(body.policies[0].abilities.export).toBeTruthy();
});
it("should allow setting sharing to false", async () => {
const { user } = await seed();
const res = await server.post("/api/collections.create", {
body: { token: user.getJwtToken(), name: "Test", sharing: false },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy();
expect(body.data.sharing).toBe(false);
});
it("should return correct policies with private collection", async () => {
const { user } = await seed();
const res = await server.post("/api/collections.create", {

View File

@@ -489,6 +489,11 @@ async function loadDocument({ id, shareId, user }) {
authorize(user, "read", document);
}
const collection = await Collection.findByPk(document.collectionId);
if (!collection.sharing) {
throw new AuthorizationError();
}
const team = await Team.findByPk(document.teamId);
if (!team.sharing) {
throw new AuthorizationError();

View File

@@ -112,6 +112,23 @@ describe("#documents.info", () => {
expect(res.status).toEqual(403);
});
it("should not return document from shareId if sharing is disabled for collection", async () => {
const { document, collection, user } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
});
collection.sharing = false;
await collection.save();
const res = await server.post("/api/documents.info", {
body: { shareId: share.id },
});
expect(res.status).toEqual(403);
});
it("should not return document from revoked shareId", async () => {
const { document, user } = await seed();
const share = await buildShare({

View File

@@ -202,7 +202,7 @@ describe("#shares.create", () => {
expect(body.data.id).toBe(share.id);
});
it("should not allow creating a share record if disabled", async () => {
it("should not allow creating a share record if team sharing disabled", async () => {
const { user, document, team } = await seed();
await team.update({ sharing: false });
const res = await server.post("/api/shares.create", {
@@ -211,6 +211,15 @@ describe("#shares.create", () => {
expect(res.status).toEqual(403);
});
it("should not allow creating a share record if collection sharing disabled", async () => {
const { user, collection, document } = await seed();
await collection.update({ sharing: false });
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/shares.create", {

View File

@@ -55,6 +55,17 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
};
});
router.post("users.count", auth(), async (ctx) => {
const { user } = ctx.state;
const counts = await User.getCounts(user.teamId);
ctx.body = {
data: {
counts,
},
};
});
router.post("users.info", auth(), async (ctx) => {
ctx.body = {
data: presentUser(ctx.state.user),

View File

@@ -2,7 +2,8 @@
import TestServer from "fetch-test-server";
import app from "../app";
import { buildUser } from "../test/factories";
import { buildTeam, buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -353,3 +354,75 @@ describe("#users.activate", () => {
expect(body).toMatchSnapshot();
});
});
describe("#users.count", () => {
it("should count active users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count admin users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, isAdmin: true });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(1);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count suspended users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildUser({ teamId: team.id, suspendedAt: new Date() });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(2);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(1);
expect(body.data.counts.active).toEqual(1);
});
it("should count invited users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(1);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.count");
expect(res.status).toEqual(401);
});
});

View File

@@ -0,0 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('collections', 'sharing', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('collections', 'sharing');
}
}

View File

@@ -24,6 +24,11 @@ const Collection = sequelize.define(
private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN,
documentStructure: DataTypes.JSONB,
sharing: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
sort: {
type: DataTypes.JSONB,
validate: {

View File

@@ -51,6 +51,9 @@ const User = sequelize.define(
isSuspended() {
return !!this.suspendedAt;
},
isInvited() {
return !this.lastActiveAt;
},
avatarUrl() {
const original = this.getDataValue("avatarUrl");
if (original) {
@@ -267,4 +270,33 @@ User.afterCreate(async (user, options) => {
]);
});
User.getCounts = async function (teamId: string) {
const countSql = `
SELECT
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
COUNT(*) as count
FROM users
WHERE "deletedAt" IS NULL
AND "teamId" = :teamId
`;
const results = await sequelize.query(countSql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId,
},
});
const counts = results[0];
return {
active: parseInt(counts.activeCount),
admins: parseInt(counts.adminCount),
all: parseInt(counts.count),
invited: parseInt(counts.invitedCount),
suspended: parseInt(counts.suspendedCount),
};
};
export default User;

View File

@@ -36,6 +36,29 @@ allow(User, ["read", "export"], Collection, (user, collection) => {
return true;
});
allow(User, "share", Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.sharing) return false;
if (collection.private) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = concat(
collection.memberships,
collection.collectionGroupMemberships
);
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
);
}
return true;
});
allow(User, ["publish", "update"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;

View File

@@ -31,12 +31,22 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
return user.teamId === document.teamId;
});
allow(User, ["update", "share"], Document, (user, document) => {
allow(User, "share", Document, (user, document) => {
if (document.archivedAt) return false;
if (document.deletedAt) return false;
// existence of collection option is not required here to account for share tokens
if (document.collection && cannot(user, "update", document.collection)) {
if (cannot(user, "share", document.collection)) {
return false;
}
return user.teamId === document.teamId;
});
allow(User, "update", Document, (user, document) => {
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (cannot(user, "update", document.collection)) {
return false;
}

View File

@@ -30,6 +30,7 @@ export default function present(collection: Collection) {
icon: collection.icon,
color: collection.color || "#4E5C6E",
private: collection.private,
sharing: collection.sharing,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt,

View File

@@ -12,7 +12,7 @@
href="/favicon-32.png"
sizes="32x32"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link
rel="search"
type="application/opensearchdescription+xml"