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:
@@ -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));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
21
app/components/DocumentsLoader.tsx
Normal file
21
app/components/DocumentsLoader.tsx
Normal 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);
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
server/migrations/20230429005039-collection-admins.js
Normal file
56
server/migrations/20230429005039-collection-admins.js
Normal 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 || [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user