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

@@ -1,6 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import { CollectionPermission } from "@shared/types";
import InputSelect, { Props, Option } from "./InputSelect";
export default function InputSelectPermission(
@@ -31,11 +32,11 @@ export default function InputSelectPermission(
options={[
{
label: t("View and edit"),
value: "read_write",
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: "read",
value: CollectionPermission.Read,
},
{
label: t("No access"),

View File

@@ -1,5 +1,6 @@
import { trim } from "lodash";
import { action, computed, observable } from "mobx";
import { CollectionPermission } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import CollectionsStore from "~/stores/CollectionsStore";
import Document from "~/models/Document";
@@ -39,7 +40,7 @@ export default class Collection extends ParanoidModel {
@Field
@observable
permission: "read" | "read_write" | void;
permission: CollectionPermission | void;
@Field
@observable

View File

@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel";
class CollectionGroupMembership extends BaseModel {
@@ -8,16 +9,11 @@ class CollectionGroupMembership extends BaseModel {
collectionId: string;
permission: string;
permission: CollectionPermission;
@computed
get isEditor(): boolean {
return this.permission === "read_write";
}
@computed
get isMaintainer(): boolean {
return this.permission === "maintainer";
return this.permission === CollectionPermission.ReadWrite;
}
}

View File

@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel";
class Membership extends BaseModel {
@@ -8,16 +9,11 @@ class Membership extends BaseModel {
collectionId: string;
permission: string;
permission: CollectionPermission;
@computed
get isEditor(): boolean {
return this.permission === "read_write";
}
@computed
get isMaintainer(): boolean {
return this.permission === "maintainer";
return this.permission === CollectionPermission.ReadWrite;
}
}

View File

@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
@@ -38,7 +39,7 @@ class CollectionNew extends React.Component<Props> {
sharing = true;
@observable
permission = "read_write";
permission = CollectionPermission.ReadWrite;
@observable
isSaving: boolean;
@@ -100,8 +101,8 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePermissionChange = (newPermission: string) => {
this.permission = newPermission;
handlePermissionChange = (permission: CollectionPermission) => {
this.permission = permission;
};
handleSharingChange = (ev: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -59,7 +59,6 @@ class AddGroupsToCollection extends React.Component<Props> {
this.props.collectionGroupMemberships.create({
collectionId: this.props.collection.id,
groupId: group.id,
permission: "read_write",
});
this.props.toasts.showToast(
t("{{ groupName }} was added to the collection", {

View File

@@ -57,7 +57,6 @@ class AddPeopleToCollection extends React.Component<Props> {
this.props.memberships.create({
collectionId: this.props.collection.id,
userId: user.id,
permission: "read_write",
});
this.props.toasts.showToast(
t("{{ userName }} was added to the collection", {

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import Group from "~/models/Group";
import GroupListItem from "~/components/GroupListItem";
@@ -10,7 +11,7 @@ import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu";
type Props = {
group: Group;
collectionGroupMembership: CollectionGroupMembership | null | undefined;
onUpdate: (permission: string) => void;
onUpdate: (permission: CollectionPermission) => void;
onRemove: () => void;
};
@@ -21,19 +22,6 @@ const CollectionGroupMemberListItem = ({
onRemove,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{
label: t("View only"),
value: "read",
},
{
label: t("View and edit"),
value: "read_write",
},
],
[t]
);
return (
<GroupListItem
@@ -43,7 +31,16 @@ const CollectionGroupMemberListItem = ({
<>
<Select
label={t("Permissions")}
options={PERMISSIONS}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
]}
value={
collectionGroupMembership
? collectionGroupMembership.permission

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Membership from "~/models/Membership";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
@@ -19,7 +20,7 @@ type Props = {
canEdit: boolean;
onAdd?: () => void;
onRemove?: () => void;
onUpdate?: (permission: string) => void;
onUpdate?: (permission: CollectionPermission) => void;
};
const MemberListItem = ({
@@ -31,19 +32,6 @@ const MemberListItem = ({
canEdit,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{
label: t("View only"),
value: "read",
},
{
label: t("View and edit"),
value: "read_write",
},
],
[t]
);
return (
<ListItem
@@ -67,7 +55,16 @@ const MemberListItem = ({
{onUpdate && (
<Select
label={t("Permissions")}
options={PERMISSIONS}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
]}
value={membership ? membership.permission : undefined}
onChange={onUpdate}
disabled={!canEdit}

View File

@@ -3,6 +3,7 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
@@ -151,7 +152,7 @@ function CollectionPermissions({ collection }: Props) {
);
const handleChangePermission = React.useCallback(
async (permission: string) => {
async (permission: CollectionPermission) => {
try {
await collection.save({
permission,
@@ -218,9 +219,10 @@ function CollectionPermissions({ collection }: Props) {
}}
/>
)}
{collection.permission === "read" && (
{collection.permission === CollectionPermission.ReadWrite && (
<Trans
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
values={{
collectionName,
}}
@@ -229,10 +231,9 @@ function CollectionPermissions({ collection }: Props) {
}}
/>
)}
{collection.permission === "read_write" && (
{collection.permission === CollectionPermission.Read && (
<Trans
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
values={{
collectionName,
}}

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
@@ -38,8 +39,8 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId);
const accessMapping = {
read_write: t("view and edit access"),
read: t("view only access"),
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};

View File

@@ -1,5 +1,6 @@
import invariant from "invariant";
import { action, runInAction } from "mobx";
import { CollectionPermission } from "@shared/types";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -45,7 +46,7 @@ export default class CollectionGroupMembershipsStore extends BaseStore<
}: {
collectionId: string;
groupId: string;
permission: string;
permission?: CollectionPermission;
}) {
const res = await client.post("/collections.add_group", {
id: collectionId,

View File

@@ -1,6 +1,7 @@
import invariant from "invariant";
import { concat, find, last } from "lodash";
import { computed, action } from "mobx";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -171,8 +172,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
@computed
get publicCollections() {
return this.orderedData.filter((collection) =>
["read", "read_write"].includes(collection.permission || "")
return this.orderedData.filter(
(collection) =>
collection.permission &&
Object.values(CollectionPermission).includes(collection.permission)
);
}

View File

@@ -1,5 +1,6 @@
import invariant from "invariant";
import { action, runInAction } from "mobx";
import { CollectionPermission } from "@shared/types";
import Membership from "~/models/Membership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -43,7 +44,7 @@ export default class MembershipsStore extends BaseStore<Membership> {
}: {
collectionId: string;
userId: string;
permission: string;
permission?: CollectionPermission;
}) {
const res = await client.post("/collections.add_user", {
id: collectionId,

View File

@@ -8,7 +8,7 @@ import {
AuthorizationError,
UserSuspendedError,
} from "../errors";
import { ContextWithState, AuthenticationTypes } from "../types";
import { ContextWithState, AuthenticationType } from "../types";
type AuthenticationOptions = {
/* An admin user role is required to access the route */
@@ -63,7 +63,7 @@ export default function auth(options: AuthenticationOptions = {}) {
if (token) {
if (String(token).match(/^[\w]{38}$/)) {
ctx.state.authType = AuthenticationTypes.API;
ctx.state.authType = AuthenticationType.API;
let apiKey;
try {
@@ -94,7 +94,7 @@ export default function auth(options: AuthenticationOptions = {}) {
throw AuthenticationError("Invalid API key");
}
} else {
ctx.state.authType = AuthenticationTypes.APP;
ctx.state.authType = AuthenticationType.APP;
user = await getUserForJWT(String(token));
}

View File

@@ -21,11 +21,12 @@ import {
Length as SimpleLength,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import { CollectionPermission } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { CollectionValidation } from "@shared/validations";
import slugify from "@server/utils/slugify";
import { NavigationNode, CollectionSort } from "~/types";
import type { NavigationNode, CollectionSort } from "~/types";
import CollectionGroup from "./CollectionGroup";
import CollectionUser from "./CollectionUser";
import Document from "./Document";
@@ -39,9 +40,6 @@ import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
// without this indirection, the app crashes on starup
type Sort = CollectionSort;
@Scopes(() => ({
withAllMemberships: {
include: [
@@ -174,9 +172,9 @@ class Collection extends ParanoidModel {
@Column
index: string | null;
@IsIn([["read", "read_write"]])
@Column
permission: "read" | "read_write" | null;
@IsIn([Object.values(CollectionPermission)])
@Column(DataType.STRING)
permission: CollectionPermission | null;
@Default(false)
@Column
@@ -193,7 +191,7 @@ class Collection extends ParanoidModel {
@Column({
type: DataType.JSONB,
validate: {
isSort(value: Sort) {
isSort(value: CollectionSort) {
if (
typeof value !== "object" ||
!value.direction ||
@@ -213,7 +211,7 @@ class Collection extends ParanoidModel {
},
},
})
sort: Sort;
sort: CollectionSort;
// getters
@@ -255,14 +253,14 @@ class Collection extends ParanoidModel {
model: Collection,
options: { transaction: Transaction }
) {
if (model.permission !== "read_write") {
if (model.permission !== CollectionPermission.ReadWrite) {
return CollectionUser.findOrCreate({
where: {
collectionId: model.id,
userId: model.createdById,
},
defaults: {
permission: "read_write",
permission: CollectionPermission.ReadWrite,
createdById: model.createdById,
},
transaction: options.transaction,

View File

@@ -8,6 +8,7 @@ import {
DataType,
Scopes,
} from "sequelize-typescript";
import { CollectionPermission } from "@shared/types";
import Collection from "./Collection";
import Group from "./Group";
import User from "./User";
@@ -33,10 +34,10 @@ import Fix from "./decorators/Fix";
@Table({ tableName: "collection_groups", modelName: "collection_group" })
@Fix
class CollectionGroup extends BaseModel {
@Default("read_write")
@IsIn([["read", "read_write", "maintainer"]])
@Column
permission: string;
@Default(CollectionPermission.ReadWrite)
@IsIn([Object.values(CollectionPermission)])
@Column(DataType.STRING)
permission: CollectionPermission;
// associations

View File

@@ -8,6 +8,7 @@ import {
DataType,
Scopes,
} from "sequelize-typescript";
import { CollectionPermission } from "@shared/types";
import Collection from "./Collection";
import User from "./User";
import BaseModel from "./base/BaseModel";
@@ -32,10 +33,10 @@ import Fix from "./decorators/Fix";
@Table({ tableName: "collection_users", modelName: "collection_user" })
@Fix
class CollectionUser extends BaseModel {
@Default("read_write")
@IsIn([["read", "read_write", "maintainer"]])
@Column
permission: string;
@Default(CollectionPermission.ReadWrite)
@IsIn([Object.values(CollectionPermission)])
@Column(DataType.STRING)
permission: CollectionPermission;
// associations

View File

@@ -19,6 +19,7 @@ import {
IsUrl,
AllowNull,
} from "sequelize-typescript";
import { CollectionPermission } from "@shared/types";
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import env from "@server/env";
import { generateAvatarUrl } from "@server/utils/avatars";
@@ -172,7 +173,7 @@ class Team extends ParanoidModel {
teamId: this.id,
createdById: userId,
sort: Collection.DEFAULT_SORT,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
},
{
transaction,

View File

@@ -1,3 +1,4 @@
import { CollectionPermission } from "@shared/types";
import { buildUser, buildTeam, buildCollection } from "@server/test/factories";
import { getTestDatabase } from "@server/test/support";
import CollectionUser from "./CollectionUser";
@@ -39,7 +40,7 @@ describe("user model", () => {
});
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const response = await user.collectionIds();
expect(response.length).toEqual(1);
@@ -52,7 +53,7 @@ describe("user model", () => {
});
const collection = await buildCollection({
teamId: team.id,
permission: "read",
permission: CollectionPermission.Read,
});
const response = await user.collectionIds();
expect(response.length).toEqual(1);
@@ -83,7 +84,7 @@ describe("user model", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
permission: CollectionPermission.Read,
});
const response = await user.collectionIds();
expect(response.length).toEqual(1);

View File

@@ -22,6 +22,7 @@ import {
AllowNull,
} from "sequelize-typescript";
import { languages } from "@shared/i18n";
import { CollectionPermission } from "@shared/types";
import { stringToColor } from "@shared/utils/color";
import env from "@server/env";
import { ValidationError } from "../errors";
@@ -219,6 +220,12 @@ class User extends ParanoidModel {
return stringToColor(this.id);
}
get defaultCollectionPermission(): CollectionPermission {
return this.isViewer
? CollectionPermission.Read
: CollectionPermission.ReadWrite;
}
/**
* Returns a code that can be used to delete this user account. The code will
* be rotated when the user signs out.
@@ -298,8 +305,8 @@ class User extends ParanoidModel {
return collectionStubs
.filter(
(c) =>
c.permission === "read" ||
c.permission === "read_write" ||
c.permission === CollectionPermission.Read ||
c.permission === CollectionPermission.ReadWrite ||
c.memberships.length > 0 ||
c.collectionGroupMemberships.length > 0
)

View File

@@ -1,3 +1,4 @@
import { CollectionPermission } from "@shared/types";
import { CollectionUser, Collection } from "@server/models";
import { buildUser, buildTeam, buildCollection } from "@server/test/factories";
import { getTestDatabase } from "@server/test/support";
@@ -9,128 +10,248 @@ afterAll(db.disconnect);
beforeEach(db.flush);
describe("read_write permission", () => {
it("should allow read write permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
describe("member", () => {
describe("read_write permission", () => {
it("should allow read write permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.ReadWrite,
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
it("should override read membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.ReadWrite,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.Read,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
it("should override read membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
describe("read permission", () => {
it("should allow read permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.Read,
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
it("should allow override with read_write membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.Read,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
});
describe("no permission", () => {
it("should allow no permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(false);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
it("should allow override with team member membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});
describe("read permission", () => {
it("should allow read permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
describe("viewer", () => {
describe("read_write permission", () => {
it("should allow read permissions for viewer", async () => {
const team = await buildTeam();
const user = await buildUser({
isViewer: true,
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.ReadWrite,
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
const collection = await buildCollection({
teamId: team.id,
permission: "read",
it("should override read membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
isViewer: true,
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.ReadWrite,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
it("should allow override with read_write membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
describe("read permission", () => {
it("should allow override with read_write membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
isViewer: true,
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.Read,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
const collection = await buildCollection({
teamId: team.id,
permission: "read",
});
describe("no permission", () => {
it("should allow no permissions for viewer", async () => {
const team = await buildTeam();
const user = await buildUser({
isViewer: true,
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(false);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
it("should allow override with team member membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
isViewer: true,
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});
describe("no permission", () => {
it("should allow no permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(false);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
it("should allow override with team member membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});

View File

@@ -1,5 +1,6 @@
import invariant from "invariant";
import { some } from "lodash";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { AdminRequiredError } from "../errors";
import { allow } from "./cancan";
@@ -57,7 +58,7 @@ allow(User, ["read", "star", "unstar"], Collection, (user, collection) => {
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read", "read_write", "maintainer"].includes(m.permission)
Object.values(CollectionPermission).includes(m.permission)
);
}
@@ -65,9 +66,6 @@ allow(User, ["read", "star", "unstar"], Collection, (user, collection) => {
});
allow(User, "share", Collection, (user, collection) => {
if (user.isViewer) {
return false;
}
if (!collection || user.teamId !== collection.teamId) {
return false;
}
@@ -78,7 +76,10 @@ allow(User, "share", Collection, (user, collection) => {
return true;
}
if (collection.permission !== "read_write") {
if (
collection.permission !== CollectionPermission.ReadWrite ||
user.isViewer
) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
@@ -87,8 +88,9 @@ allow(User, "share", Collection, (user, collection) => {
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
return some(
allMemberships,
(m) => m.permission === CollectionPermission.ReadWrite
);
}
@@ -96,9 +98,6 @@ allow(User, "share", Collection, (user, collection) => {
});
allow(User, ["publish", "update"], Collection, (user, collection) => {
if (user.isViewer) {
return false;
}
if (!collection || user.teamId !== collection.teamId) {
return false;
}
@@ -106,7 +105,10 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
return true;
}
if (collection.permission !== "read_write") {
if (
collection.permission !== CollectionPermission.ReadWrite ||
user.isViewer
) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
@@ -115,8 +117,9 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
return some(
allMemberships,
(m) => m.permission === CollectionPermission.ReadWrite
);
}
@@ -124,9 +127,6 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
});
allow(User, "delete", Collection, (user, collection) => {
if (user.isViewer) {
return false;
}
if (!collection || user.teamId !== collection.teamId) {
return false;
}
@@ -134,7 +134,10 @@ allow(User, "delete", Collection, (user, collection) => {
return true;
}
if (collection.permission !== "read_write") {
if (
collection.permission !== CollectionPermission.ReadWrite ||
user.isViewer
) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
@@ -143,8 +146,9 @@ allow(User, "delete", Collection, (user, collection) => {
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
return some(
allMemberships,
(m) => m.permission === CollectionPermission.ReadWrite
);
}

View File

@@ -1,3 +1,4 @@
import { CollectionPermission } from "@shared/types";
import {
buildUser,
buildTeam,
@@ -14,14 +15,14 @@ afterAll(db.disconnect);
beforeEach(db.flush);
describe("read_write collection", () => {
it("should allow read write permissions for team member", async () => {
it("should allow read write permissions for member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
});
const document = await buildDocument({
teamId: team.id,
@@ -37,17 +38,42 @@ describe("read_write collection", () => {
expect(abilities.share).toEqual(true);
expect(abilities.move).toEqual(true);
});
it("should allow read permissions for viewer", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
isViewer: true,
});
const collection = await buildCollection({
teamId: team.id,
permission: CollectionPermission.ReadWrite,
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
const abilities = serialize(user, document);
expect(abilities.read).toEqual(true);
expect(abilities.download).toEqual(true);
expect(abilities.update).toEqual(false);
expect(abilities.createChildDocument).toEqual(false);
expect(abilities.archive).toEqual(false);
expect(abilities.delete).toEqual(false);
expect(abilities.share).toEqual(false);
expect(abilities.move).toEqual(false);
});
});
describe("read collection", () => {
it("should allow read only permissions permissions for team member", async () => {
it("should allow read permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: "read",
permission: CollectionPermission.Read,
});
const document = await buildDocument({
teamId: team.id,

View File

@@ -209,9 +209,6 @@ allow(User, "delete", Document, (user, document) => {
if (document.deletedAt) {
return false;
}
if (user.isViewer) {
return false;
}
// allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) {
@@ -237,9 +234,6 @@ allow(User, "permanentDelete", Document, (user, document) => {
if (!document.deletedAt) {
return false;
}
if (user.isViewer) {
return false;
}
// allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) {
@@ -256,9 +250,6 @@ allow(User, "restore", Document, (user, document) => {
if (!document.deletedAt) {
return false;
}
if (user.isViewer) {
return false;
}
if (document.collection && cannot(user, "update", document.collection)) {
return false;

View File

@@ -22,6 +22,7 @@ it("should allow reading only", async () => {
expect(abilities.createGroup).toEqual(false);
expect(abilities.createIntegration).toEqual(false);
});
it("should allow admins to manage", async () => {
const team = await buildTeam();
const admin = await buildAdmin({

View File

@@ -1,10 +1,11 @@
import { CollectionPermission } from "@shared/types";
import { CollectionGroup } from "@server/models";
type Membership = {
id: string;
groupId: string;
collectionId: string;
permission: string;
permission: CollectionPermission;
};
export default (membership: CollectionGroup): Membership => {

View File

@@ -1,10 +1,11 @@
import { CollectionPermission } from "@shared/types";
import { CollectionUser } from "@server/models";
type Membership = {
id: string;
userId: string;
collectionId: string;
permission: string;
permission: CollectionPermission;
};
export default (membership: CollectionUser): Membership => {

View File

@@ -1,5 +1,6 @@
import { S3 } from "aws-sdk";
import { truncate } from "lodash";
import { CollectionPermission } from "@shared/types";
import { CollectionValidation } from "@shared/validations";
import attachmentCreator from "@server/commands/attachmentCreator";
import documentCreator from "@server/commands/documentCreator";
@@ -272,7 +273,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
length: CollectionValidation.maxDescriptionLength,
}),
createdById: fileOperation.userId,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
},
transaction,
});
@@ -292,7 +293,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
teamId: fileOperation.teamId,
createdById: fileOperation.userId,
name,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
},
{ transaction }
);

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: {

View File

@@ -1,4 +1,5 @@
import { v4 as uuidv4 } from "uuid";
import { CollectionPermission } from "@shared/types";
import {
Share,
Team,
@@ -271,7 +272,7 @@ export async function buildCollection(
name: `Test Collection ${count}`,
description: "Test collection description",
createdById: overrides.userId,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
...overrides,
});
}

View File

@@ -1,5 +1,6 @@
import TestServer from "fetch-test-server";
import { v4 as uuidv4 } from "uuid";
import { CollectionPermission } from "@shared/types";
import { sequelize as db } from "@server/database/sequelize";
import { User, Document, Collection, Team } from "@server/models";
import webService from "@server/services/web";
@@ -68,7 +69,7 @@ export const seed = async () => {
urlId: "collection",
teamId: team.id,
createdById: user.id,
permission: "read_write",
permission: CollectionPermission.ReadWrite,
},
{
transaction,

View File

@@ -1,7 +1,7 @@
import { Context } from "koa";
import { FileOperation, Team, User } from "./models";
export enum AuthenticationTypes {
export enum AuthenticationType {
API = "api",
APP = "app",
}
@@ -10,7 +10,7 @@ export type ContextWithState = Context & {
state: {
user: User;
token: string;
authType: AuthenticationTypes;
authType: AuthenticationType;
};
};

View File

@@ -1,5 +1,6 @@
import { isArrayLike } from "lodash";
import validator from "validator";
import { CollectionPermission } from "../shared/types";
import { validateColorHex } from "../shared/utils/color";
import { validateIndexCharacters } from "../shared/utils/indexCharacters";
import { ParamRequiredError, ValidationError } from "./errors";
@@ -104,3 +105,10 @@ export const assertIndexCharacters = (
throw ValidationError(message);
}
};
export const assertCollectionPermission = (
value: string,
message = "Invalid permission"
) => {
assertIn(value, [...Object.values(CollectionPermission), null], message);
};

View File

@@ -389,8 +389,8 @@
"Public document sharing permissions were updated": "Public document sharing permissions were updated",
"Could not update public document sharing": "Could not update public document sharing",
"The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.": "The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.",
"Team members can view documents in the <em>{{ collectionName }}</em> collection by default.": "Team members can view documents in the <em>{{ collectionName }}</em> collection by default.",
"Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.": "Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.",
"Team members can view documents in the <em>{{ collectionName }}</em> collection by default.": "Team members can view documents in the <em>{{ collectionName }}</em> collection by default.",
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
"Additional access": "Additional access",

View File

@@ -28,6 +28,11 @@ export enum IntegrationType {
Embed = "embed",
}
export enum CollectionPermission {
Read = "read",
ReadWrite = "read_write",
}
export type IntegrationSettings<T> = T extends IntegrationType.Embed
? { url: string }
: T extends IntegrationType.Post