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

@@ -61,7 +61,7 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
return; return;
} }
const { activeCollectionId } = ui; const { activeCollectionId } = ui;
if (!activeCollectionId || !can.update) { if (!activeCollectionId || !can.createDocument) {
return; return;
} }
history.push(newDocumentPath(activeCollectionId)); history.push(newDocumentPath(activeCollectionId));

View File

@@ -48,7 +48,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>( const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
0 0
); );
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
const [activeNode, setActiveNode] = React.useState<number>(0); const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]); const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const [itemRefs, setItemRefs] = React.useState< const [itemRefs, setItemRefs] = React.useState<
@@ -79,19 +78,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
setActiveNode(0); setActiveNode(0);
}, [searchTerm]); }, [searchTerm]);
React.useEffect(() => {
let results;
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = items.filter((item) => item.type === "collection");
}
setInitialScrollOffset(0);
setNodes(results);
}, [searchTerm, items, searchIndex]);
React.useEffect(() => { React.useEffect(() => {
setItemRefs((itemRefs) => setItemRefs((itemRefs) =>
map( map(
@@ -105,6 +91,22 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
onSelect(selectedNode); onSelect(selectedNode);
}, [selectedNode, onSelect]); }, [selectedNode, onSelect]);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
: [item];
}
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => item.type === "collection")
.flatMap(includeDescendants);
}
const nodes = getNodes();
const scrollNodeIntoView = React.useCallback( const scrollNodeIntoView = React.useCallback(
(node: number) => { (node: number) => {
if (itemRefs[node] && itemRefs[node].current) { if (itemRefs[node] && itemRefs[node].current) {
@@ -130,7 +132,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
scrollOffset: number; scrollOffset: number;
}; };
const itemsHeight = itemCount * itemSize; const itemsHeight = itemCount * itemSize;
return itemsHeight < height ? 0 : scrollOffset; return itemsHeight < Number(height) ? 0 : scrollOffset;
} }
return 0; return 0;
}; };
@@ -145,7 +147,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id)); const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length); const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset); setInitialScrollOffset(scrollOffset);
setNodes(newNodes);
}; };
const expand = (node: number) => { const expand = (node: number) => {
@@ -156,9 +157,18 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1)); newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
const scrollOffset = calculateInitialScrollOffset(newNodes.length); const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset); setInitialScrollOffset(scrollOffset);
setNodes(newNodes);
}; };
React.useEffect(() => {
collections.orderedData
.filter(
(collection) => expandedNodes.includes(collection.id) || searchTerm
)
.forEach((collection) => {
collection.fetchDocuments();
});
}, [collections, expandedNodes, searchTerm]);
const isSelected = (node: number) => { const isSelected = (node: number) => {
if (!selectedNode) { if (!selectedNode) {
return false; return false;
@@ -169,7 +179,8 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
return selectedNodeId === nodeId; return selectedNodeId === nodeId;
}; };
const hasChildren = (node: number) => nodes[node].children.length > 0; const hasChildren = (node: number) =>
nodes[node].children.length > 0 || nodes[node].type === "collection";
const toggleCollapse = (node: number) => { const toggleCollapse = (node: number) => {
if (!hasChildren(node)) { if (!hasChildren(node)) {
@@ -219,7 +230,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
} else if (doc?.isStarred) { } else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />; icon = <StarredIcon color={theme.yellow} />;
} else { } else {
icon = <DocumentIcon />; icon = <DocumentIcon color={theme.textSecondary} />;
} }
path = ancestors(node) path = ancestors(node)
@@ -281,12 +292,14 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
switch (ev.key) { switch (ev.key) {
case "ArrowDown": { case "ArrowDown": {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation();
setActiveNode(next()); setActiveNode(next());
scrollNodeIntoView(next()); scrollNodeIntoView(next());
break; break;
} }
case "ArrowUp": { case "ArrowUp": {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation();
if (activeNode === 0) { if (activeNode === 0) {
focusSearchInput(); focusSearchInput();
} else { } else {

View File

@@ -0,0 +1,21 @@
import { observer } from "mobx-react";
import * as React from "react";
import Collection from "~/models/Collection";
type Props = {
enabled: boolean;
collection: Collection;
children: React.ReactNode;
};
function DocumentsLoader({ collection, enabled, children }: Props) {
React.useEffect(() => {
if (enabled) {
void collection.fetchDocuments();
}
}, [collection, enabled]);
return <>{children}</>;
}
export default observer(DocumentsLoader);

View File

@@ -44,7 +44,7 @@ const CollectionLink: React.FC<Props> = ({
const { dialogs, documents, collections } = useStores(); const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false); const [isEditing, setIsEditing] = React.useState(false);
const canUpdate = usePolicy(collection).update; const can = usePolicy(collection);
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const inStarredSection = useStarredContext(); const inStarredSection = useStarredContext();
@@ -105,7 +105,7 @@ const CollectionLink: React.FC<Props> = ({
} }
} }
}, },
canDrop: () => canUpdate, canDrop: () => can.createDocument,
collect: (monitor) => ({ collect: (monitor) => ({
isOver: !!monitor.isOver({ isOver: !!monitor.isOver({
shallow: true, shallow: true,
@@ -118,6 +118,10 @@ const CollectionLink: React.FC<Props> = ({
setIsEditing(isEditing); setIsEditing(isEditing);
}, []); }, []);
const handleMouseEnter = React.useCallback(() => {
void collection.fetchDocuments();
}, [collection]);
const context = useActionContext({ const context = useActionContext({
activeCollectionId: collection.id, activeCollectionId: collection.id,
inStarredSection, inStarredSection,
@@ -134,6 +138,7 @@ const CollectionLink: React.FC<Props> = ({
}} }}
expanded={expanded} expanded={expanded}
onDisclosureClick={onDisclosureClick} onDisclosureClick={onDisclosureClick}
onMouseEnter={handleMouseEnter}
icon={ icon={
<CollectionIcon collection={collection} expanded={expanded} /> <CollectionIcon collection={collection} expanded={expanded} />
} }
@@ -147,7 +152,7 @@ const CollectionLink: React.FC<Props> = ({
title={collection.name} title={collection.name}
onSubmit={handleTitleChange} onSubmit={handleTitleChange}
onEditing={handleTitleEditing} onEditing={handleTitleEditing}
canUpdate={canUpdate} canUpdate={can.update}
/> />
} }
exact={false} exact={false}

View File

@@ -2,8 +2,12 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useDrop } from "react-dnd"; import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import DelayedMount from "~/components/DelayedMount";
import DocumentsLoader from "~/components/DocumentsLoader";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
@@ -11,6 +15,7 @@ import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder"; import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
import Folder from "./Folder"; import Folder from "./Folder";
import PlaceholderCollections from "./PlaceholderCollections";
import { DragObject } from "./SidebarLink"; import { DragObject } from "./SidebarLink";
import useCollectionDocuments from "./useCollectionDocuments"; import useCollectionDocuments from "./useCollectionDocuments";
@@ -63,28 +68,42 @@ function CollectionLinkChildren({
return ( return (
<Folder expanded={expanded}> <Folder expanded={expanded}>
{isDraggingAnyDocument && can.update && manualSort && ( {isDraggingAnyDocument && can.createDocument && manualSort && (
<DropCursor <DropCursor
isActiveDrop={isOverReorder} isActiveDrop={isOverReorder}
innerRef={dropToReorder} innerRef={dropToReorder}
position="top" position="top"
/> />
)} )}
{childDocuments.map((node, index) => ( <DocumentsLoader collection={collection} enabled={expanded}>
<DocumentLink {!childDocuments && (
key={node.id} <ResizingHeightContainer hideOverflow>
node={node} <DelayedMount>
collection={collection} <Loading />
activeDocument={documents.active} </DelayedMount>
prefetchDocument={prefetchDocument} </ResizingHeightContainer>
isDraft={node.isDraft} )}
depth={2} {childDocuments?.map((node, index) => (
index={index} <DocumentLink
/> key={node.id}
))} node={node}
{childDocuments.length === 0 && <EmptyCollectionPlaceholder />} collection={collection}
activeDocument={documents.active}
prefetchDocument={prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
{childDocuments?.length === 0 && <EmptyCollectionPlaceholder />}
</DocumentsLoader>
</Folder> </Folder>
); );
} }
const Loading = styled(PlaceholderCollections)`
margin-left: 44px;
min-height: 90px;
`;
export default observer(CollectionLinkChildren); export default observer(CollectionLinkChildren);

View File

@@ -26,10 +26,14 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
collectionId, collectionId,
documentId documentId
); );
const targetId = collectionId || documentId; invariant(
invariant(targetId, "Must provide either collectionId or documentId"); collectionId || documentId,
"Must provide either collectionId or documentId"
);
const canCollection = usePolicy(collectionId);
const canDocument = usePolicy(documentId);
const can = usePolicy(targetId);
const handleRejection = React.useCallback(() => { const handleRejection = React.useCallback(() => {
showToast( showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"), t("Document not supported try Markdown, Plain text, HTML, or Word"),
@@ -39,7 +43,11 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
); );
}, [t, showToast]); }, [t, showToast]);
if (disabled || !can.update) { if (
disabled ||
(collectionId && !canCollection.createDocument) ||
(documentId && !canDocument.createChildDocument)
) {
return children; return children;
} }

View File

@@ -2,9 +2,9 @@ import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import PlaceholderText from "~/components/PlaceholderText"; import PlaceholderText from "~/components/PlaceholderText";
function PlaceholderCollections() { function PlaceholderCollections(props: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<Wrapper> <Wrapper {...props}>
<PlaceholderText /> <PlaceholderText />
<PlaceholderText delay={0.2} /> <PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} /> <PlaceholderText delay={0.4} />

View File

@@ -8,8 +8,8 @@ export default function useCollectionDocuments(
activeDocument: Document | undefined activeDocument: Document | undefined
) { ) {
return React.useMemo(() => { return React.useMemo(() => {
if (!collection) { if (!collection?.sortedDocuments) {
return []; return undefined;
} }
const insertDraftDocument = const insertDraftDocument =

View File

@@ -196,7 +196,7 @@ class WebsocketProvider extends React.Component<Props> {
} }
try { try {
await collections.fetch(collectionId, { await collection?.fetchDocuments({
force: true, force: true,
}); });
} catch (err) { } catch (err) {
@@ -293,6 +293,10 @@ class WebsocketProvider extends React.Component<Props> {
collections.add(event); collections.add(event);
}); });
this.socket.on("collections.update", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on( this.socket.on(
"collections.delete", "collections.delete",
action((event: WebsocketEntityDeletedEvent) => { action((event: WebsocketEntityDeletedEvent) => {

View File

@@ -73,9 +73,11 @@ export default function useCollectionTrees(): NavigationNode[] {
return addParent(addCollectionId(addDepth(addType(collectionNode)))); return addParent(addCollectionId(addDepth(addType(collectionNode))));
}; };
const key = collections.orderedData.map((o) => o.documents?.length).join("-");
const collectionTrees = React.useMemo( const collectionTrees = React.useMemo(
() => collections.orderedData.map(getCollectionTree), () => collections.orderedData.map(getCollectionTree),
[collections.orderedData] // eslint-disable-next-line react-hooks/exhaustive-deps
[collections.orderedData, key]
); );
return collectionTrees; return collectionTrees;

View File

@@ -200,14 +200,14 @@ function CollectionMenu({
{ {
type: "button", type: "button",
title: t("New document"), title: t("New document"),
visible: can.update, visible: can.createDocument,
onClick: handleNewDocument, onClick: handleNewDocument,
icon: <NewDocumentIcon />, icon: <NewDocumentIcon />,
}, },
{ {
type: "button", type: "button",
title: t("Import document"), title: t("Import document"),
visible: can.update, visible: can.createDocument,
onClick: handleImportDocument, onClick: handleImportDocument,
icon: <ImportIcon />, icon: <ImportIcon />,
}, },
@@ -261,6 +261,7 @@ function CollectionMenu({
collection, collection,
can.unstar, can.unstar,
can.star, can.star,
can.createDocument,
can.update, can.update,
can.delete, can.delete,
handleStar, handleStar,

View File

@@ -132,7 +132,7 @@ function DocumentMenu({
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => { ...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
const can = policies.abilities(collection.id); const can = policies.abilities(collection.id);
if (can.update) { if (can.createDocument) {
filtered.push({ filtered.push({
type: "button", type: "button",
onClick: (ev) => onClick: (ev) =>

View File

@@ -60,7 +60,6 @@ export default abstract class BaseModel {
}; };
updateFromJson = (data: any) => { updateFromJson = (data: any) => {
// const isNew = !data.id && !this.id && this.isNew;
set(this, { ...data, isNew: false }); set(this, { ...data, isNew: false });
this.persistedAttributes = this.toAPI(); this.persistedAttributes = this.toAPI();
}; };

View File

@@ -1,5 +1,5 @@
import { trim } from "lodash"; import { trim } from "lodash";
import { action, computed, observable } from "mobx"; import { action, computed, observable, runInAction } from "mobx";
import { import {
CollectionPermission, CollectionPermission,
FileOperationFormat, FileOperationFormat,
@@ -18,6 +18,8 @@ export default class Collection extends ParanoidModel {
@observable @observable
isSaving: boolean; isSaving: boolean;
isFetching = false;
@Field @Field
@observable @observable
id: string; id: string;
@@ -57,32 +59,34 @@ export default class Collection extends ParanoidModel {
direction: "asc" | "desc"; direction: "asc" | "desc";
}; };
documents: NavigationNode[]; @observable
documents?: NavigationNode[];
url: string; url: string;
urlId: string; urlId: string;
@computed @computed
get isEmpty(): boolean { get isEmpty(): boolean | undefined {
if (!this.documents) {
return undefined;
}
return ( return (
this.documents.length === 0 && this.documents.length === 0 &&
this.store.rootStore.documents.inCollection(this.id).length === 0 this.store.rootStore.documents.inCollection(this.id).length === 0
); );
} }
@computed /**
get documentIds(): string[] { * Convenience method to return if a collection is considered private.
const results: string[] = []; * This means that a membership is required to view it rather than just being
* a workspace member.
const travelNodes = (nodes: NavigationNode[]) => *
nodes.forEach((node) => { * @returns boolean
results.push(node.id); */
travelNodes(node.children); get isPrivate(): boolean {
}); return !this.permission;
travelNodes(this.documents);
return results;
} }
@computed @computed
@@ -98,10 +102,35 @@ export default class Collection extends ParanoidModel {
} }
@computed @computed
get sortedDocuments() { get sortedDocuments(): NavigationNode[] | undefined {
if (!this.documents) {
return undefined;
}
return sortNavigationNodes(this.documents, this.sort); return sortNavigationNodes(this.documents, this.sort);
} }
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;
}
if (this.documents && options?.force !== true) {
return;
}
try {
this.isFetching = true;
const { data } = await client.post("/collections.documents", {
id: this.id,
});
runInAction("Collection#fetchDocuments", () => {
this.documents = data;
});
} finally {
this.isFetching = false;
}
};
/** /**
* Updates the document identified by the given id in the collection in memory. * Updates the document identified by the given id in the collection in memory.
* Does not update the document in the database. * Does not update the document in the database.
@@ -110,6 +139,10 @@ export default class Collection extends ParanoidModel {
*/ */
@action @action
updateDocument(document: Pick<Document, "id" | "title" | "url">) { updateDocument(document: Pick<Document, "id" | "title" | "url">) {
if (!this.documents) {
throw new Error("Collection documents not loaded");
}
const travelNodes = (nodes: NavigationNode[]) => const travelNodes = (nodes: NavigationNode[]) =>
nodes.forEach((node) => { nodes.forEach((node) => {
if (node.id === document.id) { if (node.id === document.id) {
@@ -131,6 +164,10 @@ export default class Collection extends ParanoidModel {
*/ */
@action @action
removeDocument(documentId: string) { removeDocument(documentId: string) {
if (!this.documents) {
throw new Error("Collection documents not loaded");
}
this.documents = this.documents.filter(function f(node): boolean { this.documents = this.documents.filter(function f(node): boolean {
if (node.id === documentId) { if (node.id === documentId) {
return false; return false;
@@ -163,7 +200,7 @@ export default class Collection extends ParanoidModel {
}); });
}; };
if (this.documents) { if (this.sortedDocuments) {
travelNodes(this.sortedDocuments); travelNodes(this.sortedDocuments);
} }

View File

@@ -1,4 +1,4 @@
import { computed } from "mobx"; import { observable } from "mobx";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
@@ -9,12 +9,8 @@ class CollectionGroupMembership extends BaseModel {
collectionId: string; collectionId: string;
@observable
permission: CollectionPermission; permission: CollectionPermission;
@computed
get isEditor(): boolean {
return this.permission === CollectionPermission.ReadWrite;
}
} }
export default CollectionGroupMembership; export default CollectionGroupMembership;

View File

@@ -1,4 +1,4 @@
import { computed } from "mobx"; import { observable } from "mobx";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
@@ -9,12 +9,8 @@ class Membership extends BaseModel {
collectionId: string; collectionId: string;
@observable
permission: CollectionPermission; permission: CollectionPermission;
@computed
get isEditor(): boolean {
return this.permission === CollectionPermission.ReadWrite;
}
} }
export default Membership; export default Membership;

View File

@@ -148,7 +148,7 @@ function CollectionScene() {
> >
<DropToImport <DropToImport
accept={documents.importFileTypes.join(", ")} accept={documents.importFileTypes.join(", ")}
disabled={!can.update} disabled={!can.createDocument}
collectionId={collection.id} collectionId={collection.id}
> >
<CenteredContent withStickyHeader> <CenteredContent withStickyHeader>
@@ -159,7 +159,7 @@ function CollectionScene() {
<HeadingWithIcon $isStarred={collection.isStarred}> <HeadingWithIcon $isStarred={collection.isStarred}>
<HeadingIcon collection={collection} size={40} expanded /> <HeadingIcon collection={collection} size={40} expanded />
{collection.name} {collection.name}
{!collection.permission && ( {collection.isPrivate && (
<Tooltip <Tooltip
tooltip={t( tooltip={t(
"This collection is only visible to those given access" "This collection is only visible to those given access"

View File

@@ -37,9 +37,11 @@ function EmptyCollection({ collection }: Props) {
}} }}
/> />
<br /> <br />
{can.update && <Trans>Get started by creating a new one!</Trans>} {can.createDocument && (
<Trans>Get started by creating a new one!</Trans>
)}
</Text> </Text>
{can.update && ( {can.createDocument && (
<Empty> <Empty>
<Link to={newDocumentPath(collection.id)}> <Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon />} neutral> <Button icon={<NewDocumentIcon />} neutral>

View File

@@ -1,13 +1,10 @@
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import CollectionGroupMembership from "~/models/CollectionGroupMembership"; import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import Group from "~/models/Group"; import Group from "~/models/Group";
import GroupListItem from "~/components/GroupListItem"; import GroupListItem from "~/components/GroupListItem";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu"; import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu";
import InputMemberPermissionSelect from "./InputMemberPermissionSelect";
type Props = { type Props = {
group: Group; group: Group;
@@ -21,57 +18,27 @@ const CollectionGroupMemberListItem = ({
collectionGroupMembership, collectionGroupMembership,
onUpdate, onUpdate,
onRemove, onRemove,
}: Props) => { }: Props) => (
const { t } = useTranslation(); <GroupListItem
group={group}
return ( showAvatar
<GroupListItem renderActions={({ openMembersModal }) => (
group={group} <>
showAvatar <InputMemberPermissionSelect
renderActions={({ openMembersModal }) => ( value={
<> collectionGroupMembership
<Select ? collectionGroupMembership.permission
label={t("Permissions")} : undefined
options={[ }
{ onChange={onUpdate}
label: t("View only"), />
value: CollectionPermission.Read, <CollectionGroupMemberMenu
}, onMembers={openMembersModal}
{ onRemove={onRemove}
label: t("View and edit"), />
value: CollectionPermission.ReadWrite, </>
}, )}
]} />
value={ );
collectionGroupMembership
? collectionGroupMembership.permission
: undefined
}
onChange={onUpdate}
ariaLabel={t("Permissions")}
labelHidden
nude
/>
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
</>
)}
/>
);
};
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
box-shadow: none;
color: ${s("textSecondary")};
select {
margin: 0;
}
` as React.ComponentType<SelectProps>;
export default CollectionGroupMemberListItem; export default CollectionGroupMemberListItem;

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
export default function InputMemberPermissionSelect(
props: Partial<SelectProps>
) {
const { t } = useTranslation();
return (
<Select
label={t("Permissions")}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("Admin"),
value: CollectionPermission.Admin,
},
]}
ariaLabel={t("Permissions")}
labelHidden
nude
{...props}
/>
);
}
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
box-shadow: none;
color: ${s("textSecondary")};
select {
margin: 0;
}
` as React.ComponentType<SelectProps>;

View File

@@ -1,8 +1,6 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import Membership from "~/models/Membership"; import Membership from "~/models/Membership";
import User from "~/models/User"; import User from "~/models/User";
@@ -10,10 +8,10 @@ import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
import ListItem from "~/components/List/Item"; import ListItem from "~/components/List/Item";
import Time from "~/components/Time"; import Time from "~/components/Time";
import MemberMenu from "~/menus/MemberMenu"; import MemberMenu from "~/menus/MemberMenu";
import InputMemberPermissionSelect from "./InputMemberPermissionSelect";
type Props = { type Props = {
user: User; user: User;
@@ -54,24 +52,10 @@ const MemberListItem = ({
actions={ actions={
<Flex align="center" gap={8}> <Flex align="center" gap={8}>
{onUpdate && ( {onUpdate && (
<Select <InputMemberPermissionSelect
label={t("Permissions")}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
]}
value={membership ? membership.permission : undefined} value={membership ? membership.permission : undefined}
onChange={onUpdate} onChange={onUpdate}
disabled={!canEdit} disabled={!canEdit}
ariaLabel={t("Permissions")}
labelHidden
nude
/> />
)} )}
{canEdit && ( {canEdit && (
@@ -90,16 +74,4 @@ const MemberListItem = ({
); );
}; };
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
box-shadow: none;
color: ${s("textSecondary")};
select {
margin: 0;
}
` as React.ComponentType<SelectProps>;
export default observer(MemberListItem); export default observer(MemberListItem);

View File

@@ -211,7 +211,7 @@ function CollectionPermissions({ collectionId }: Props) {
value={collection.permission || ""} value={collection.permission || ""}
/> />
<PermissionExplainer size="small"> <PermissionExplainer size="small">
{!collection.permission && ( {collection.isPrivate && (
<Trans <Trans
defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default." defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default."
values={{ values={{

View File

@@ -28,13 +28,15 @@ function DocumentMove({ document }: Props) {
null null
); );
const moveOptions = React.useMemo(() => { const items = React.useMemo(() => {
// filter out the document itself and also its parent doc if any // Filter out the document itself and its existing parent doc, if any.
const nodes = flatten(collectionTrees.map(flattenTree)).filter( const nodes = flatten(collectionTrees.map(flattenTree)).filter(
(node) => node.id !== document.id && node.id !== document.parentDocumentId (node) => node.id !== document.id && node.id !== document.parentDocumentId
); );
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) { if (document.isTemplate) {
// only show collections with children stripped off to prevent node expansion
return nodes return nodes
.filter((node) => node.type === "collection") .filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] })); .map((node) => ({ ...node, children: [] }));
@@ -80,11 +82,7 @@ function DocumentMove({ document }: Props) {
return ( return (
<FlexContainer column> <FlexContainer column>
<DocumentExplorer <DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
items={moveOptions}
onSubmit={move}
onSelect={selectPath}
/>
<Footer justify="space-between" align="center" gap={8}> <Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary"> <StyledText type="secondary">
{selectedPath ? ( {selectedPath ? (

View File

@@ -34,8 +34,14 @@ export default class CollectionsStore extends BaseStore<Collection> {
super(rootStore, Collection); super(rootStore, Collection);
} }
/**
* Returns the currently active collection, or undefined if not in the context
* of a collection.
*
* @returns The active Collection or undefined
*/
@computed @computed
get active(): Collection | null | undefined { get active(): Collection | undefined {
return this.rootStore.ui.activeCollectionId return this.rootStore.ui.activeCollectionId
? this.data.get(this.rootStore.ui.activeCollectionId) ? this.data.get(this.rootStore.ui.activeCollectionId)
: undefined; : undefined;
@@ -92,7 +98,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
url, url,
}; };
results.push([node]); results.push([node]);
travelDocuments(collection.documents, id, [node]);
if (collection.documents) {
travelDocuments(collection.documents, id, [node]);
}
}); });
} }
@@ -132,13 +141,9 @@ export default class CollectionsStore extends BaseStore<Collection> {
// remove all locally cached policies for documents in the collection as they // remove all locally cached policies for documents in the collection as they
// are now invalid // are now invalid
if (params.sharing !== undefined) { if (params.sharing !== undefined) {
const collection = this.get(params.id); this.rootStore.documents.inCollection(params.id).forEach((document) => {
this.rootStore.policies.remove(document.id);
if (collection) { });
collection.documentIds.forEach((id) => {
this.rootStore.policies.remove(id);
});
}
} }
return result; return result;

View File

@@ -139,7 +139,7 @@ export default class DocumentsStore extends BaseStore<Document> {
rootInCollection(collectionId: string): Document[] { rootInCollection(collectionId: string): Document[] {
const collection = this.rootStore.collections.get(collectionId); const collection = this.rootStore.collections.get(collectionId);
if (!collection) { if (!collection || !collection.sortedDocuments) {
return []; return [];
} }

View File

@@ -0,0 +1,56 @@
'use strict';
module.exports = {
async up(queryInterface) {
if (process.env.DEPLOYMENT === "hosted") {
return;
}
await queryInterface.sequelize.transaction(async (transaction) => {
// Convert collection members to admins where the user is the only
// membership in the collection.
await queryInterface.sequelize.query(
`UPDATE collection_users cu
SET permission = 'admin'
WHERE (
SELECT COUNT(*)
FROM collection_users
WHERE "collectionId" = cu."collectionId"
AND permission = 'read_write'
) = 1;`,
{
type: queryInterface.sequelize.QueryTypes.SELECT,
}
);
// Convert collection members to admins where the collection is private
// and they currently have read_write permission
await queryInterface.sequelize.query(
`UPDATE collection_users
SET permission = 'admin'
WHERE permission = 'read_write'
AND "collectionId" IN (
SELECT c."id"
FROM collections c
WHERE c.permission IS NULL
);`,
{
type: queryInterface.sequelize.QueryTypes.SELECT,
}
);
});
},
async down(queryInterface) {
if (process.env.DEPLOYMENT === "hosted") {
return;
}
await queryInterface.sequelize.query(
"UPDATE collection_users SET permission = 'read_write' WHERE permission = 'admin'",
{
type: queryInterface.sequelize.QueryTypes.SELECT,
}
);
}
};

View File

@@ -254,21 +254,17 @@ class Collection extends ParanoidModel {
model: Collection, model: Collection,
options: { transaction: Transaction } options: { transaction: Transaction }
) { ) {
if (model.permission !== CollectionPermission.ReadWrite) { return CollectionUser.findOrCreate({
return CollectionUser.findOrCreate({ where: {
where: { collectionId: model.id,
collectionId: model.id, userId: model.createdById,
userId: model.createdById, },
}, defaults: {
defaults: { permission: CollectionPermission.Admin,
permission: CollectionPermission.ReadWrite, createdById: model.createdById,
createdById: model.createdById, },
}, transaction: options.transaction,
transaction: options.transaction, });
});
}
return undefined;
} }
// associations // associations
@@ -396,6 +392,17 @@ class Collection extends ParanoidModel {
}); });
} }
/**
* Convenience method to return if a collection is considered private.
* This means that a membership is required to view it rather than just being
* a workspace member.
*
* @returns boolean
*/
get isPrivate() {
return !this.permission;
}
getDocumentTree = (documentId: string): NavigationNode | null => { getDocumentTree = (documentId: string): NavigationNode | null => {
if (!this.documentStructure) { if (!this.documentStructure) {
return null; return null;

View File

@@ -473,6 +473,25 @@ class Document extends ParanoidModel {
// instance methods // instance methods
/**
* Whether this document is considered active or not. A document is active if
* it has not been archived or deleted.
*
* @returns boolean
*/
get isActive(): boolean {
return !this.archivedAt && !this.deletedAt;
}
/**
* Convenience method that returns whether this document is a draft.
*
* @returns boolean
*/
get isDraft(): boolean {
return !this.publishedAt;
}
get titleWithDefault(): string { get titleWithDefault(): string {
return this.title || "Untitled"; return this.title || "Untitled";
} }

View File

@@ -380,8 +380,9 @@ class User extends ParanoidModel {
return collectionStubs return collectionStubs
.filter( .filter(
(c) => (c) =>
c.permission === CollectionPermission.Read || Object.values(CollectionPermission).includes(
c.permission === CollectionPermission.ReadWrite || c.permission as CollectionPermission
) ||
c.memberships.length > 0 || c.memberships.length > 0 ||
c.collectionGroupMemberships.length > 0 c.collectionGroupMemberships.length > 0
) )

View File

@@ -1,14 +1,71 @@
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import { CollectionUser, Collection } from "@server/models"; import { CollectionUser, Collection } from "@server/models";
import { buildUser, buildTeam, buildCollection } from "@server/test/factories"; import {
buildUser,
buildTeam,
buildCollection,
buildAdmin,
} from "@server/test/factories";
import { setupTestDatabase } from "@server/test/support"; import { setupTestDatabase } from "@server/test/support";
import { serialize } from "./index"; import { serialize } from "./index";
setupTestDatabase(); setupTestDatabase();
describe("admin", () => {
it("should allow updating collection but not reading documents", async () => {
const team = await buildTeam();
const user = await buildAdmin({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.readDocument).toEqual(false);
expect(abilities.createDocument).toEqual(false);
expect(abilities.share).toEqual(false);
expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true);
});
});
describe("member", () => { describe("member", () => {
describe("admin permission", () => {
it("should allow updating collection", 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.Admin,
});
// 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.readDocument).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(true);
});
});
describe("read_write permission", () => { describe("read_write permission", () => {
it("should allow read write permissions for team member", async () => { it("should allow read write documents for team member", async () => {
const team = await buildTeam(); const team = await buildTeam();
const user = await buildUser({ const user = await buildUser({
teamId: team.id, teamId: team.id,
@@ -19,8 +76,9 @@ describe("member", () => {
}); });
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.readDocument).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(false);
}); });
it("should override read membership permission", async () => { it("should override read membership permission", async () => {
@@ -44,8 +102,9 @@ describe("member", () => {
}).findByPk(collection.id); }).findByPk(collection.id);
const abilities = serialize(user, reloaded); const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.readDocument).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(false);
}); });
}); });
@@ -86,8 +145,9 @@ describe("member", () => {
}).findByPk(collection.id); }).findByPk(collection.id);
const abilities = serialize(user, reloaded); const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.readDocument).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(false);
}); });
}); });
@@ -103,8 +163,10 @@ describe("member", () => {
}); });
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(false); expect(abilities.read).toEqual(false);
expect(abilities.update).toEqual(false); expect(abilities.readDocument).toEqual(false);
expect(abilities.createDocument).toEqual(false);
expect(abilities.share).toEqual(false); expect(abilities.share).toEqual(false);
expect(abilities.update).toEqual(false);
}); });
it("should allow override with team member membership permission", async () => { it("should allow override with team member membership permission", async () => {
@@ -128,8 +190,10 @@ describe("member", () => {
}).findByPk(collection.id); }).findByPk(collection.id);
const abilities = serialize(user, reloaded); const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.readDocument).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(false);
}); });
}); });
}); });
@@ -148,6 +212,8 @@ describe("viewer", () => {
}); });
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.readDocument).toEqual(true);
expect(abilities.createDocument).toEqual(false);
expect(abilities.update).toEqual(false); expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false); expect(abilities.share).toEqual(false);
}); });
@@ -174,8 +240,9 @@ describe("viewer", () => {
}).findByPk(collection.id); }).findByPk(collection.id);
const abilities = serialize(user, reloaded); const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.readDocument).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(false);
}); });
}); });
@@ -202,8 +269,10 @@ describe("viewer", () => {
}).findByPk(collection.id); }).findByPk(collection.id);
const abilities = serialize(user, reloaded); const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.readDocument).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(false);
}); });
}); });
@@ -246,8 +315,10 @@ describe("viewer", () => {
}).findByPk(collection.id); }).findByPk(collection.id);
const abilities = serialize(user, reloaded); const abilities = serialize(user, reloaded);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.readDocument).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
expect(abilities.update).toEqual(false);
}); });
}); });
}); });

View File

@@ -40,26 +40,29 @@ allow(User, "move", Collection, (user, collection) => {
throw AdminRequiredError(); throw AdminRequiredError();
}); });
allow(User, ["read", "star", "unstar"], Collection, (user, collection) => { allow(User, "read", Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) { if (!collection || user.teamId !== collection.teamId) {
return false; return false;
} }
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
if (!collection.permission) { if (collection.isPrivate) {
invariant( return includesMembership(collection, Object.values(CollectionPermission));
collection.memberships, }
"membership should be preloaded, did you forget withMembership scope?"
); return true;
const allMemberships = [ });
...collection.memberships,
...collection.collectionGroupMemberships, allow(User, ["star", "unstar"], Collection, (user, collection) => {
]; if (!collection || user.teamId !== collection.teamId) {
return some(allMemberships, (m) => return false;
Object.values(CollectionPermission).includes(m.permission) }
);
if (collection.isPrivate) {
return includesMembership(collection, Object.values(CollectionPermission));
} }
return true; return true;
@@ -72,32 +75,56 @@ allow(User, "share", Collection, (user, collection) => {
if (!collection.sharing) { if (!collection.sharing) {
return false; return false;
} }
if (user.isAdmin) {
return true;
}
if ( if (
collection.permission !== CollectionPermission.ReadWrite || collection.permission !== CollectionPermission.ReadWrite ||
user.isViewer user.isViewer
) { ) {
invariant( return includesMembership(collection, [
collection.memberships, CollectionPermission.ReadWrite,
"membership should be preloaded, did you forget withMembership scope?" CollectionPermission.Admin,
); ]);
const allMemberships = [
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(
allMemberships,
(m) => m.permission === CollectionPermission.ReadWrite
);
} }
return true; return true;
}); });
allow(User, ["publish", "update"], Collection, (user, collection) => { allow(User, "readDocument", Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) {
return false;
}
if (collection.isPrivate) {
return includesMembership(collection, Object.values(CollectionPermission));
}
return true;
});
allow(
User,
["updateDocument", "createDocument", "deleteDocument"],
Collection,
(user, collection) => {
if (!collection || user.teamId !== collection.teamId) {
return false;
}
if (
collection.permission !== CollectionPermission.ReadWrite ||
user.isViewer
) {
return includesMembership(collection, [
CollectionPermission.ReadWrite,
CollectionPermission.Admin,
]);
}
return true;
}
);
allow(User, ["update", "delete"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) { if (!collection || user.teamId !== collection.teamId) {
return false; return false;
} }
@@ -105,56 +132,19 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
return true; return true;
} }
if ( return includesMembership(collection, [CollectionPermission.Admin]);
collection.permission !== CollectionPermission.ReadWrite ||
user.isViewer
) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = [
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(
allMemberships,
(m) => m.permission === CollectionPermission.ReadWrite
);
}
return true;
}); });
allow(User, "delete", Collection, (user, collection) => { function includesMembership(
if (!collection || user.teamId !== collection.teamId) { collection: Collection,
return false; memberships: CollectionPermission[]
} ) {
if (user.isAdmin) { invariant(
return true; collection.memberships,
} "memberships should be preloaded, did you forget withMembership scope?"
);
if ( return some(
collection.permission !== CollectionPermission.ReadWrite || [...collection.memberships, ...collection.collectionGroupMemberships],
user.isViewer (m) => memberships.includes(m.permission)
) { );
invariant( }
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = [
...collection.memberships,
...collection.collectionGroupMemberships,
];
return some(
allMemberships,
(m) => m.permission === CollectionPermission.ReadWrite
);
}
if (user.id === collection.createdById) {
return true;
}
throw AdminRequiredError();
});

View File

@@ -16,7 +16,10 @@ allow(User, ["read", "comment"], Document, (user, document) => {
} }
// existence of collection option is not required here to account for share tokens // existence of collection option is not required here to account for share tokens
if (document.collection && cannot(user, "read", document.collection)) { if (
document.collection &&
cannot(user, "readDocument", document.collection)
) {
return false; return false;
} }
@@ -29,7 +32,10 @@ allow(User, "download", Document, (user, document) => {
} }
// existence of collection option is not required here to account for share tokens // existence of collection option is not required here to account for share tokens
if (document.collection && cannot(user, "read", document.collection)) { if (
document.collection &&
cannot(user, "readDocument", document.collection)
) {
return false; return false;
} }
@@ -44,16 +50,7 @@ allow(User, "download", Document, (user, document) => {
}); });
allow(User, "star", Document, (user, document) => { allow(User, "star", Document, (user, document) => {
if (!document) { if (!document || !document.isActive || document.template) {
return false;
}
if (document.archivedAt) {
return false;
}
if (document.deletedAt) {
return false;
}
if (document.template) {
return false; return false;
} }
@@ -62,7 +59,7 @@ allow(User, "star", Document, (user, document) => {
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (cannot(user, "read", document.collection)) { if (cannot(user, "readDocument", document.collection)) {
return false; return false;
} }
} }
@@ -83,7 +80,7 @@ allow(User, "unstar", Document, (user, document) => {
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (cannot(user, "read", document.collection)) { if (cannot(user, "readDocument", document.collection)) {
return false; return false;
} }
} }
@@ -114,13 +111,7 @@ allow(User, "share", Document, (user, document) => {
}); });
allow(User, "update", Document, (user, document) => { allow(User, "update", Document, (user, document) => {
if (!document) { if (!document || !document.isActive) {
return false;
}
if (document.archivedAt) {
return false;
}
if (document.deletedAt) {
return false; return false;
} }
@@ -130,7 +121,7 @@ allow(User, "update", Document, (user, document) => {
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (cannot(user, "update", document.collection)) { if (cannot(user, "updateDocument", document.collection)) {
return false; return false;
} }
} }
@@ -139,63 +130,42 @@ allow(User, "update", Document, (user, document) => {
}); });
allow(User, "createChildDocument", Document, (user, document) => { allow(User, "createChildDocument", Document, (user, document) => {
if (!document) { if (!document || !document.isActive || document.isDraft) {
return false;
}
if (document.archivedAt) {
return false;
}
if (document.deletedAt) {
return false; return false;
} }
if (document.template) { if (document.template) {
return false; return false;
} }
if (!document.publishedAt) {
return false;
}
invariant( invariant(
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (cannot(user, "update", document.collection)) { if (cannot(user, "updateDocument", document.collection)) {
return false; return false;
} }
return user.teamId === document.teamId; return user.teamId === document.teamId;
}); });
allow(User, "move", Document, (user, document) => { allow(User, "move", Document, (user, document) => {
if (!document) { if (!document || !document.isActive) {
return false; return false;
} }
if (document.archivedAt) { if (
return false; document.collection &&
} cannot(user, "updateDocument", document.collection)
if (document.deletedAt) { ) {
return false;
}
if (document.collection && cannot(user, "update", document.collection)) {
return false; return false;
} }
return user.teamId === document.teamId; return user.teamId === document.teamId;
}); });
allow(User, ["pin", "unpin"], Document, (user, document) => { allow(User, ["pin", "unpin"], Document, (user, document) => {
if (!document) { if (!document || !document.isActive || document.isDraft) {
return false;
}
if (document.archivedAt) {
return false;
}
if (document.deletedAt) {
return false; return false;
} }
if (document.template) { if (document.template) {
return false; return false;
} }
if (!document.publishedAt) {
return false;
}
invariant( invariant(
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
@@ -207,46 +177,30 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
}); });
allow(User, ["subscribe", "unsubscribe"], Document, (user, document) => { allow(User, ["subscribe", "unsubscribe"], Document, (user, document) => {
if (!document) { if (!document || !document.isActive || document.isDraft) {
return false;
}
if (document.archivedAt) {
return false;
}
if (document.deletedAt) {
return false; return false;
} }
if (document.template) { if (document.template) {
return false; return false;
} }
if (!document.publishedAt) {
return false;
}
invariant( invariant(
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (cannot(user, "read", document.collection)) { if (cannot(user, "readDocument", document.collection)) {
return false; return false;
} }
return user.teamId === document.teamId; return user.teamId === document.teamId;
}); });
allow(User, ["pinToHome"], Document, (user, document) => { allow(User, "pinToHome", Document, (user, document) => {
if (!document) { if (
return false; !document ||
} !document.isActive ||
if (document.archivedAt) { document.isDraft ||
return false; document.template
} ) {
if (document.deletedAt) {
return false;
}
if (document.template) {
return false;
}
if (!document.publishedAt) {
return false; return false;
} }
@@ -254,23 +208,23 @@ allow(User, ["pinToHome"], Document, (user, document) => {
}); });
allow(User, "delete", Document, (user, document) => { allow(User, "delete", Document, (user, document) => {
if (!document) { if (!document || document.deletedAt) {
return false;
}
if (document.deletedAt) {
return false; return false;
} }
// allow deleting document without a collection // allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) { if (
document.collection &&
cannot(user, "deleteDocument", document.collection)
) {
return false; return false;
} }
// unpublished drafts can always be deleted // unpublished drafts can always be deleted by their owner
if ( if (
!document.deletedAt && !document.deletedAt &&
!document.publishedAt && document.isDraft &&
user.teamId === document.teamId user.id === document.createdById
) { ) {
return true; return true;
} }
@@ -279,15 +233,15 @@ allow(User, "delete", Document, (user, document) => {
}); });
allow(User, "permanentDelete", Document, (user, document) => { allow(User, "permanentDelete", Document, (user, document) => {
if (!document) { if (!document || !document.deletedAt) {
return false;
}
if (!document.deletedAt) {
return false; return false;
} }
// allow deleting document without a collection // allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) { if (
document.collection &&
cannot(user, "updateDocument", document.collection)
) {
return false; return false;
} }
@@ -295,14 +249,14 @@ allow(User, "permanentDelete", Document, (user, document) => {
}); });
allow(User, "restore", Document, (user, document) => { allow(User, "restore", Document, (user, document) => {
if (!document) { if (!document || !document.deletedAt) {
return false;
}
if (!document.deletedAt) {
return false; return false;
} }
if (document.collection && cannot(user, "update", document.collection)) { if (
document.collection &&
cannot(user, "updateDocument", document.collection)
) {
return false; return false;
} }
@@ -310,23 +264,14 @@ allow(User, "restore", Document, (user, document) => {
}); });
allow(User, "archive", Document, (user, document) => { allow(User, "archive", Document, (user, document) => {
if (!document) { if (!document || !document.isActive || document.isDraft) {
return false;
}
if (!document.publishedAt) {
return false;
}
if (document.archivedAt) {
return false;
}
if (document.deletedAt) {
return false; return false;
} }
invariant( invariant(
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (cannot(user, "update", document.collection)) { if (cannot(user, "updateDocument", document.collection)) {
return false; return false;
} }
return user.teamId === document.teamId; return user.teamId === document.teamId;
@@ -340,7 +285,7 @@ allow(User, "unarchive", Document, (user, document) => {
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (cannot(user, "update", document.collection)) { if (cannot(user, "updateDocument", document.collection)) {
return false; return false;
} }
if (!document.archivedAt) { if (!document.archivedAt) {
@@ -360,17 +305,14 @@ allow(
); );
allow(User, "unpublish", Document, (user, document) => { allow(User, "unpublish", Document, (user, document) => {
if (!document) { if (!document || !document.isActive || document.isDraft) {
return false; return false;
} }
invariant( invariant(
document.collection, document.collection,
"collection is missing, did you forget to include in the query scope?" "collection is missing, did you forget to include in the query scope?"
); );
if (!document.publishedAt || !!document.deletedAt || !!document.archivedAt) { if (cannot(user, "updateDocument", document.collection)) {
return false;
}
if (cannot(user, "update", document.collection)) {
return false; return false;
} }
return user.teamId === document.teamId; return user.teamId === document.teamId;

View File

@@ -1,3 +1,4 @@
import { colorPalette } from "@shared/utils/collections";
import Collection from "@server/models/Collection"; import Collection from "@server/models/Collection";
export default function presentCollection(collection: Collection) { export default function presentCollection(collection: Collection) {
@@ -10,12 +11,11 @@ export default function presentCollection(collection: Collection) {
sort: collection.sort, sort: collection.sort,
icon: collection.icon, icon: collection.icon,
index: collection.index, index: collection.index,
color: collection.color || "#4E5C6E", color: collection.color || colorPalette[0],
permission: collection.permission, permission: collection.permission,
sharing: collection.sharing, sharing: collection.sharing,
createdAt: collection.createdAt, createdAt: collection.createdAt,
updatedAt: collection.updatedAt, updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt, deletedAt: collection.deletedAt,
documents: collection.documentStructure || [],
}; };
} }

View File

@@ -170,20 +170,30 @@ export default class WebsocketsProcessor {
if (!collection) { if (!collection) {
return; return;
} }
return socketio.to(`team-${collection.teamId}`).emit("entities", {
event: event.name, return socketio
collectionIds: [ .to(
{ collection.permission
id: collection.id, ? `collection-${event.collectionId}`
updatedAt: collection.updatedAt, : `team-${collection.teamId}`
}, )
], .emit(event.name, presentCollection(collection));
});
} }
case "collections.delete": { case "collections.delete": {
const collection = await Collection.findByPk(event.collectionId, {
paranoid: false,
});
if (!collection) {
return;
}
return socketio return socketio
.to(`collection-${event.collectionId}`) .to(
collection.permission
? `collection-${event.collectionId}`
: `team-${collection.teamId}`
)
.emit(event.name, { .emit(event.name, {
modelId: event.collectionId, modelId: event.collectionId,
}); });

View File

@@ -11,7 +11,7 @@ export default class CollectionCreatedNotificationsTask extends BaseTask<
const collection = await Collection.findByPk(event.collectionId); const collection = await Collection.findByPk(event.collectionId);
// We only send notifications for collections visible to the entire team // We only send notifications for collections visible to the entire team
if (!collection || !collection.permission) { if (!collection || collection.isPrivate) {
return; return;
} }

View File

@@ -56,7 +56,7 @@ export default class ExportJSONTask extends ExportTask {
private async addCollectionToArchive(zip: JSZip, collection: Collection) { private async addCollectionToArchive(zip: JSZip, collection: Collection) {
const output: CollectionJSONExport = { const output: CollectionJSONExport = {
collection: { collection: {
...omit(presentCollection(collection), ["url", "documents"]), ...omit(presentCollection(collection), ["url"]),
description: collection.description description: collection.description
? parser.parse(collection.description) ? parser.parse(collection.description)
: null, : null,

View File

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

View File

@@ -695,6 +695,12 @@ describe("#documents.list", () => {
const { user, collection } = await seed(); const { user, collection } = await seed();
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.list", { const res = await server.post("/api/documents.list", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -766,12 +772,18 @@ describe("#documents.list", () => {
const { user, collection } = await seed(); const { user, collection } = await seed();
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.create({ await CollectionUser.update(
createdById: user.id, {
collectionId: collection.id, userId: user.id,
userId: user.id, permission: CollectionPermission.Read,
permission: CollectionPermission.Read, },
}); {
where: {
createdById: user.id,
collectionId: collection.id,
},
}
);
const res = await server.post("/api/documents.list", { const res = await server.post("/api/documents.list", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -891,6 +903,12 @@ describe("#documents.drafts", () => {
await document.save(); await document.save();
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.drafts", { const res = await server.post("/api/documents.drafts", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -1792,6 +1810,12 @@ describe("#documents.viewed", () => {
}); });
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.viewed", { const res = await server.post("/api/documents.viewed", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -2565,12 +2589,18 @@ describe("#documents.update", () => {
await document.save(); await document.save();
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.create({ await CollectionUser.update(
createdById: user.id, {
collectionId: collection.id, userId: user.id,
userId: user.id, permission: CollectionPermission.ReadWrite,
permission: CollectionPermission.ReadWrite, },
}); {
where: {
createdById: user.id,
collectionId: collection.id,
},
}
);
const res = await server.post("/api/documents.update", { const res = await server.post("/api/documents.update", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -2634,18 +2664,24 @@ describe("#documents.update", () => {
}); });
it("allows editing by read-write collection user", async () => { it("allows editing by read-write collection user", async () => {
const { admin, document, collection } = await seed(); const { user, document, collection } = await seed();
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.create({ await CollectionUser.update(
collectionId: collection.id, {
userId: admin.id, createdById: user.id,
createdById: admin.id, permission: CollectionPermission.ReadWrite,
permission: CollectionPermission.ReadWrite, },
}); {
where: {
collectionId: collection.id,
userId: user.id,
},
}
);
const res = await server.post("/api/documents.update", { const res = await server.post("/api/documents.update", {
body: { body: {
token: admin.getJwtToken(), token: user.getJwtToken(),
id: document.id, id: document.id,
text: "Changed text", text: "Changed text",
}, },
@@ -2653,19 +2689,25 @@ describe("#documents.update", () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.text).toBe("Changed text"); 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 () => { it("does not allow editing by read-only collection user", async () => {
const { user, document, collection } = await seed(); const { user, document, collection } = await seed();
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.create({ await CollectionUser.update(
collectionId: collection.id, {
userId: user.id, createdById: user.id,
createdById: user.id, permission: CollectionPermission.Read,
permission: CollectionPermission.Read, },
}); {
where: {
collectionId: collection.id,
userId: user.id,
},
}
);
const res = await server.post("/api/documents.update", { const res = await server.post("/api/documents.update", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -2680,6 +2722,12 @@ describe("#documents.update", () => {
const { user, document, collection } = await seed(); const { user, document, collection } = await seed();
collection.permission = CollectionPermission.Read; collection.permission = CollectionPermission.Read;
await collection.save(); await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/documents.update", { const res = await server.post("/api/documents.update", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -2831,10 +2879,6 @@ describe("#documents.update", () => {
expect(body.data.document.collectionId).toBe(collection.id); expect(body.data.document.collectionId).toBe(collection.id);
expect(body.data.document.title).toBe("Updated title"); expect(body.data.document.title).toBe("Updated title");
expect(body.data.document.text).toBe("Updated text"); 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({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collectionId); }).findByPk(collectionId);
authorize(user, "read", collection); authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the // index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column // collection.documentStructure rather than a database column
@@ -342,7 +342,7 @@ router.post(
const collection = await Collection.scope({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collectionId); }).findByPk(collectionId);
authorize(user, "read", collection); authorize(user, "readDocument", collection);
} }
const collectionIds = collectionId const collectionIds = collectionId
@@ -599,7 +599,7 @@ router.post(
} }
if (document.collection) { if (document.collection) {
authorize(user, "update", collection); authorize(user, "updateDocument", collection);
} }
if (document.deletedAt) { if (document.deletedAt) {
@@ -686,7 +686,7 @@ router.post(
const collection = await Collection.scope({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collectionId); }).findByPk(collectionId);
authorize(user, "read", collection); authorize(user, "readDocument", collection);
} }
if (userId) { if (userId) {
@@ -780,7 +780,7 @@ router.post(
const collection = await Collection.scope({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collectionId); }).findByPk(collectionId);
authorize(user, "read", collection); authorize(user, "readDocument", collection);
} }
let collaboratorIds = undefined; let collaboratorIds = undefined;
@@ -921,7 +921,7 @@ router.post(
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collectionId!); }).findByPk(collectionId!);
} }
authorize(user, "publish", collection); authorize(user, "createDocument", collection);
} }
collection = await sequelize.transaction(async (transaction) => { collection = await sequelize.transaction(async (transaction) => {
@@ -982,7 +982,7 @@ router.post(
const collection = await Collection.scope({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collectionId); }).findByPk(collectionId);
authorize(user, "update", collection); authorize(user, "updateDocument", collection);
if (parentDocumentId) { if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, { const parent = await Document.findByPk(parentDocumentId, {
@@ -1205,7 +1205,7 @@ router.post(
teamId: user.teamId, teamId: user.teamId,
}, },
}); });
authorize(user, "publish", collection); authorize(user, "createDocument", collection);
let parentDocument; let parentDocument;
if (parentDocumentId) { if (parentDocumentId) {
@@ -1282,7 +1282,7 @@ router.post(
teamId: user.teamId, teamId: user.teamId,
}, },
}); });
authorize(user, "publish", collection); authorize(user, "createDocument", collection);
} }
let parentDocument; let parentDocument;

View File

@@ -8,7 +8,9 @@ const DocumentsSortParamsSchema = z.object({
/** Specifies the attributes by which documents will be sorted in the list */ /** Specifies the attributes by which documents will be sorted in the list */
sort: z sort: z
.string() .string()
.refine((val) => ["createdAt", "updatedAt", "index", "title"].includes(val)) .refine((val) =>
["createdAt", "updatedAt", "publishedAt", "index", "title"].includes(val)
)
.default("updatedAt"), .default("updatedAt"),
/** Specifies the sort order with respect to sort field */ /** 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 { buildDocument, buildUser } from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support"; import { seed, getTestServer } from "@server/test/support";
@@ -141,6 +141,12 @@ describe("#revisions.list", () => {
await Revision.createFromDocument(document); await Revision.createFromDocument(document);
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.destroy({
where: {
userId: user.id,
collectionId: collection.id,
},
});
const res = await server.post("/api/revisions.list", { const res = await server.post("/api/revisions.list", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),

View File

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

View File

@@ -86,6 +86,7 @@ export enum IntegrationService {
export enum CollectionPermission { export enum CollectionPermission {
Read = "read", Read = "read",
ReadWrite = "read_write", ReadWrite = "read_write",
Admin = "admin",
} }
export type IntegrationSettings<T> = T extends IntegrationType.Embed export type IntegrationSettings<T> = T extends IntegrationType.Embed