diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 177bb2ab2..0787562b6 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -32,13 +32,13 @@ import { toast } from "sonner"; import { ExportContentType, TeamPreference } from "@shared/types"; import MarkdownHelper from "@shared/utils/MarkdownHelper"; import { getEventFiles } from "@shared/utils/files"; -import SharePopover from "~/scenes/Document/components/SharePopover"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; import DocumentPublish from "~/scenes/DocumentPublish"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import DuplicateDialog from "~/components/DuplicateDialog"; +import SharePopover from "~/components/Sharing"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; import env from "~/env"; @@ -199,7 +199,7 @@ export const publishDocument = createAction({ } const document = stores.documents.get(activeDocumentId); return ( - !!document?.isDraft && stores.policies.abilities(activeDocumentId).update + !!document?.isDraft && stores.policies.abilities(activeDocumentId).publish ); }, perform: async ({ activeDocumentId, stores, t }) => { @@ -352,7 +352,6 @@ export const shareDocument = createAction({ share={share} sharedParent={sharedParent} onRequestClose={stores.dialogs.closeAllModals} - hideTitle visible /> ), @@ -485,7 +484,7 @@ export const duplicateDocument = createAction({ icon: , keywords: "copy", visible: ({ activeDocumentId, stores }) => - !!activeDocumentId && stores.policies.abilities(activeDocumentId).update, + !!activeDocumentId && stores.policies.abilities(activeDocumentId).duplicate, perform: async ({ activeDocumentId, t, stores }) => { if (!activeDocumentId) { return; @@ -952,4 +951,5 @@ export const rootDocumentActions = [ openDocumentComments, openDocumentHistory, openDocumentInsights, + shareDocument, ]; diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index 6426f7733..fae86a31e 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -81,12 +81,12 @@ const DocumentBreadcrumb: React.FC = ({ icon: , to: collectionPath(collection.url), }; - } else if (document.collectionId && !collection) { + } else if (document.isCollectionDeleted) { collectionNode = { type: "route", title: t("Deleted Collection"), icon: undefined, - to: collectionPath("deleted-collection"), + to: "", }; } diff --git a/app/components/Input.tsx b/app/components/Input.tsx index be1bf2c4c..7a06ec2ba 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -114,7 +114,10 @@ export const LabelText = styled.div` `; export interface Props - extends React.InputHTMLAttributes { + extends Omit< + React.InputHTMLAttributes, + "prefix" + > { type?: "text" | "email" | "checkbox" | "search" | "textarea"; labelHidden?: boolean; label?: string; @@ -122,6 +125,9 @@ export interface Props short?: boolean; margin?: string | number; error?: string; + /** Optional component that appears inside the input before the textarea and any icon */ + prefix?: React.ReactNode; + /** Optional icon that appears inside the input before the textarea */ icon?: React.ReactNode; /** Like autoFocus, but also select any text in the input */ autoSelect?: boolean; @@ -185,6 +191,7 @@ function Input( className, short, flex, + prefix, labelHidden, onFocus, onBlur, @@ -205,6 +212,7 @@ function Input( wrappedLabel ))} + {prefix} {icon && {icon}} {type === "textarea" ? ( + props: Partial & { permissions: Permission[] } ) { const { t } = useTranslation(); return ( + {doneButton} + + + ) : ( + + + {backButton} + + {doneButton} + + + ))} + + {picker && ( +
+ +
+ )} + +
+ + + + + {team.sharing && can.share && !collectionSharingDisabled && ( + <> + {document.members.length ? : null} + + + )} +
+ + ); +} + +const Picker = observer( + ({ + document, + query, + onInvite, + }: { + document: Document; + query: string; + onInvite: (user: User) => Promise; + }) => { + const { users } = useStores(); + const { t } = useTranslation(); + const user = useCurrentUser(); + + const fetchUsersByQuery = useThrottledCallback( + (query) => users.fetchPage({ query }), + 250 + ); + + const suggestions = React.useMemo( + () => + users.notInDocument(document.id, query).filter((u) => u.id !== user.id), + [users, users.orderedData, document.id, document.members, user.id, query] + ); + + React.useEffect(() => { + if (query) { + void fetchUsersByQuery(query); + } + }, [query, fetchUsersByQuery]); + + return suggestions.length ? ( + <> + {suggestions.map((suggestion) => ( + onInvite(suggestion)} + title={suggestion.name} + subtitle={ + suggestion.isSuspended + ? t("Suspended") + : suggestion.isInvited + ? t("Invited") + : suggestion.isViewer + ? t("Viewer") + : suggestion.email + ? suggestion.email + : t("Member") + } + image={ + + } + actions={} + /> + ))} + + ) : ( + {t("No matches")} + ); + } +); + +const DocumentOtherAccessList = observer( + ({ + document, + children, + }: { + document: Document; + children: React.ReactNode; + }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const collection = document.collection; + const usersInCollection = useUsersInCollection(collection); + const user = useCurrentUser(); + + return ( + <> + {collection ? ( + <> + {collection.permission ? ( + + + + } + title={t("All members")} + subtitle={t("Everyone in the workspace")} + actions={ + + {collection?.permission === CollectionPermission.ReadWrite + ? t("Can edit") + : t("Can view")} + + } + /> + ) : usersInCollection ? ( + + + + } + title={collection.name} + subtitle={t("Everyone in the collection")} + actions={{t("Can view")}} + /> + ) : ( + } + title={user.name} + subtitle={t("You have full access")} + actions={{t("Can edit")}} + /> + )} + {children} + + ) : document.isDraft ? ( + <> + } + title={document.createdBy.name} + actions={ + + {t("Can edit")} + + } + /> + {children} + + ) : ( + <> + {children} + + + + } + title={t("Other people")} + subtitle={t("Other workspace members may have access")} + actions={ + + } + /> + + )} + + ); + } +); + +const AccessTooltip = ({ + children, + tooltip, +}: { + children?: React.ReactNode; + tooltip?: string; +}) => { + const { t } = useTranslation(); + + return ( + + + {children} + + + + + + ); +}; + +// TODO: Temp until Button/NudeButton styles are normalized +const Wrapper = styled.div` + ${NudeButton}:${hover}, + ${NudeButton}[aria-expanded="true"] { + background: ${(props) => darken(0.05, props.theme.buttonNeutralBackground)}; + } +`; + +const Separator = styled.div` + border-top: 1px dashed ${s("divider")}; + margin: 12px 0; +`; + +const HeaderInput = styled(Flex)` + position: sticky; + z-index: 1; + top: 0; + background: ${s("menuBackground")}; + color: ${s("textTertiary")}; + border-bottom: 1px solid ${s("inputBorder")}; + padding: 0 24px 12px; + margin-top: 0; + margin-left: -24px; + margin-right: -24px; + margin-bottom: 12px; + cursor: text; + + &:before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -20px; + height: 20px; + background: ${s("menuBackground")}; + } +`; + +export default observer(SharePopover); diff --git a/app/components/Sharing/index.tsx b/app/components/Sharing/index.tsx new file mode 100644 index 000000000..757dd58d6 --- /dev/null +++ b/app/components/Sharing/index.tsx @@ -0,0 +1,3 @@ +import SharePopover from "./SharePopover"; + +export default SharePopover; diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index d14f45419..a840836c7 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -24,6 +24,7 @@ import Collections from "./components/Collections"; import DragPlaceholder from "./components/DragPlaceholder"; import HistoryNavigation from "./components/HistoryNavigation"; import Section from "./components/Section"; +import SharedWithMe from "./components/SharedWithMe"; import SidebarAction from "./components/SidebarAction"; import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton"; import SidebarLink from "./components/SidebarLink"; @@ -122,6 +123,9 @@ function AppSidebar() { /> )} +
+ +
diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 70975d695..e27d2942a 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -26,6 +26,7 @@ import DropToImport from "./DropToImport"; import EditableTitle, { RefHandle } from "./EditableTitle"; import Folder from "./Folder"; import Relative from "./Relative"; +import { useSharedContext } from "./SharedContext"; import SidebarLink, { DragObject } from "./SidebarLink"; import { useStarredContext } from "./StarredContext"; @@ -64,12 +65,19 @@ function InnerDocumentLink( const [isEditing, setIsEditing] = React.useState(false); const editableTitleRef = React.useRef(null); const inStarredSection = useStarredContext(); + const inSharedSection = useSharedContext(); React.useEffect(() => { - if (isActiveDocument && hasChildDocuments) { + if (isActiveDocument && (hasChildDocuments || inSharedSection)) { void fetchChildDocuments(node.id); } - }, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]); + }, [ + fetchChildDocuments, + node.id, + hasChildDocuments, + inSharedSection, + isActiveDocument, + ]); const pathToNode = React.useMemo( () => collection?.pathToDocument(node.id).map((entry) => entry.id), diff --git a/app/components/Sidebar/components/SharedContext.ts b/app/components/Sidebar/components/SharedContext.ts new file mode 100644 index 000000000..9391466f1 --- /dev/null +++ b/app/components/Sidebar/components/SharedContext.ts @@ -0,0 +1,7 @@ +import * as React from "react"; + +const SharedContext = React.createContext(undefined); + +export const useSharedContext = () => React.useContext(SharedContext); + +export default SharedContext; diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx new file mode 100644 index 000000000..94b67c613 --- /dev/null +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -0,0 +1,89 @@ +import fractionalIndex from "fractional-index"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Pagination } from "@shared/constants"; +import UserMembership from "~/models/UserMembership"; +import DelayedMount from "~/components/DelayedMount"; +import Flex from "~/components/Flex"; +import useCurrentUser from "~/hooks/useCurrentUser"; +import usePaginatedRequest from "~/hooks/usePaginatedRequest"; +import useStores from "~/hooks/useStores"; +import DropCursor from "./DropCursor"; +import Header from "./Header"; +import PlaceholderCollections from "./PlaceholderCollections"; +import Relative from "./Relative"; +import SharedContext from "./SharedContext"; +import SharedWithMeLink from "./SharedWithMeLink"; +import SidebarLink from "./SidebarLink"; +import { useDropToReorderUserMembership } from "./useDragAndDrop"; + +function SharedWithMe() { + const { userMemberships } = useStores(); + const { t } = useTranslation(); + const user = useCurrentUser(); + + const { loading, next, end, error, page } = + usePaginatedRequest(userMemberships.fetchPage, { + limit: Pagination.sidebarLimit, + }); + + // Drop to reorder document + const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership( + () => fractionalIndex(null, user.memberships[0].index) + ); + + React.useEffect(() => { + if (error) { + toast.error(t("Could not load shared documents")); + } + }, [error, t]); + + if (!user.memberships.length) { + return null; + } + + return ( + + +
+ + {reorderMonitor.isDragging && ( + + )} + {user.memberships + .slice(0, page * Pagination.sidebarLimit) + .map((membership) => ( + + ))} + {!end && ( + + )} + {loading && ( + + + + + + )} + +
+
+
+ ); +} + +export default observer(SharedWithMe); diff --git a/app/components/Sidebar/components/SharedWithMeLink.tsx b/app/components/Sidebar/components/SharedWithMeLink.tsx new file mode 100644 index 000000000..1181037cf --- /dev/null +++ b/app/components/Sidebar/components/SharedWithMeLink.tsx @@ -0,0 +1,158 @@ +import fractionalIndex from "fractional-index"; +import { observer } from "mobx-react"; +import * as React from "react"; +import styled from "styled-components"; +import UserMembership from "~/models/UserMembership"; +import Fade from "~/components/Fade"; +import useBoolean from "~/hooks/useBoolean"; +import useStores from "~/hooks/useStores"; +import DocumentMenu from "~/menus/DocumentMenu"; +import DocumentLink from "./DocumentLink"; +import DropCursor from "./DropCursor"; +import Folder from "./Folder"; +import Relative from "./Relative"; +import SidebarLink from "./SidebarLink"; +import { + useDragUserMembership, + useDropToReorderUserMembership, +} from "./useDragAndDrop"; +import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon"; + +type Props = { + userMembership: UserMembership; +}; + +function SharedWithMeLink({ userMembership }: Props) { + const { ui, collections, documents } = useStores(); + const { fetchChildDocuments } = documents; + const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); + const { documentId } = userMembership; + const isActiveDocument = documentId === ui.activeDocumentId; + const [expanded, setExpanded] = React.useState( + userMembership.documentId === ui.activeDocumentId + ); + + React.useEffect(() => { + if (userMembership.documentId === ui.activeDocumentId) { + setExpanded(true); + } + }, [userMembership.documentId, ui.activeDocumentId]); + + React.useEffect(() => { + if (documentId) { + void documents.fetch(documentId); + } + }, [documentId, documents]); + + React.useEffect(() => { + if (isActiveDocument && userMembership.documentId) { + void fetchChildDocuments(userMembership.documentId); + } + }, [fetchChildDocuments, isActiveDocument, userMembership.documentId]); + + const handleDisclosureClick = React.useCallback( + (ev: React.MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + setExpanded((prevExpanded) => !prevExpanded); + }, + [] + ); + + const { icon } = useSidebarLabelAndIcon(userMembership); + const [{ isDragging }, draggableRef] = useDragUserMembership(userMembership); + + const getIndex = () => { + const next = userMembership?.next(); + return fractionalIndex(userMembership?.index || null, next?.index || null); + }; + const [reorderMonitor, dropToReorderRef] = + useDropToReorderUserMembership(getIndex); + + const displayChildDocuments = expanded && !isDragging; + + if (documentId) { + const document = documents.get(documentId); + if (!document) { + return null; + } + + const { emoji } = document; + const label = emoji + ? document.title.replace(emoji, "") + : document.titleWithDefault; + const collection = document.collectionId + ? collections.get(document.collectionId) + : undefined; + + const node = document.asNavigationNode; + const childDocuments = node.children; + const hasChildDocuments = childDocuments.length > 0; + + return ( + <> + + + + + ) : undefined + } + /> + + + + {childDocuments.map((node, index) => ( + + ))} + + {reorderMonitor.isDragging && ( + + )} + + + ); + } + + return null; +} + +const Draggable = styled.div<{ $isDragging?: boolean }>` + position: relative; + transition: opacity 250ms ease; + opacity: ${(props) => (props.$isDragging ? 0.1 : 1)}; +`; + +export default observer(SharedWithMeLink); diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index 0378a94a5..ee64fc63e 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -1,10 +1,11 @@ import fractionalIndex from "fractional-index"; import { Location } from "history"; import { observer } from "mobx-react"; +import { StarredIcon } from "outline-icons"; import * as React from "react"; import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; -import styled from "styled-components"; +import styled, { useTheme } from "styled-components"; import Star from "~/models/Star"; import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; @@ -22,7 +23,7 @@ import { useDropToCreateStar, useDropToReorderStar, } from "./useDragAndDrop"; -import { useStarLabelAndIcon } from "./useStarLabelAndIcon"; +import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon"; type Props = { star: Star; @@ -36,6 +37,7 @@ function useLocationStateStarred() { } function StarredLink({ star }: Props) { + const theme = useTheme(); const { ui, collections, documents } = useStores(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { documentId, collectionId } = star; @@ -70,7 +72,10 @@ function StarredLink({ star }: Props) { const next = star?.next(); return fractionalIndex(star?.index || null, next?.index || null); }; - const { label, icon } = useStarLabelAndIcon(star); + const { label, icon } = useSidebarLabelAndIcon( + star, + + ); const [{ isDragging }, draggableRef] = useDragStar(star); const [reorderStarMonitor, dropToReorderRef] = useDropToReorderStar(getIndex); const [createStarMonitor, dropToStarRef] = useDropToCreateStar(getIndex); diff --git a/app/components/Sidebar/components/useDragAndDrop.ts b/app/components/Sidebar/components/useDragAndDrop.tsx similarity index 56% rename from app/components/Sidebar/components/useDragAndDrop.ts rename to app/components/Sidebar/components/useDragAndDrop.tsx index b95104fc2..f0f92c275 100644 --- a/app/components/Sidebar/components/useDragAndDrop.ts +++ b/app/components/Sidebar/components/useDragAndDrop.tsx @@ -1,11 +1,15 @@ import fractionalIndex from "fractional-index"; +import { StarredIcon } from "outline-icons"; import * as React from "react"; import { ConnectDragSource, useDrag, useDrop } from "react-dnd"; import { getEmptyImage } from "react-dnd-html5-backend"; +import { useTheme } from "styled-components"; import Star from "~/models/Star"; +import UserMembership from "~/models/UserMembership"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; import { DragObject } from "./SidebarLink"; -import { useStarLabelAndIcon } from "./useStarLabelAndIcon"; +import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon"; /** * Hook for shared logic that allows dragging a Starred item @@ -16,7 +20,11 @@ export function useDragStar( star: Star ): [{ isDragging: boolean }, ConnectDragSource] { const id = star.id; - const { label: title, icon } = useStarLabelAndIcon(star); + const theme = useTheme(); + const { label: title, icon } = useSidebarLabelAndIcon( + star, + + ); const [{ isDragging }, draggableRef, preview] = useDrag({ type: "star", item: () => ({ id, title, icon }), @@ -81,3 +89,53 @@ export function useDropToReorderStar(getIndex?: () => string) { }), }); } + +export function useDragUserMembership( + userMembership: UserMembership +): [{ isDragging: boolean }, ConnectDragSource] { + const id = userMembership.id; + const { label: title, icon } = useSidebarLabelAndIcon(userMembership); + + const [{ isDragging }, draggableRef, preview] = useDrag({ + type: "userMembership", + item: () => ({ + id, + title, + icon, + }), + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + canDrag: () => true, + }); + + React.useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }); + }, [preview]); + + return [{ isDragging }, draggableRef]; +} + +/** + * Hook for shared logic that allows dropping user memberships to reorder + * + * @param getIndex A function to get the index of the current item where the membership should be inserted. + */ +export function useDropToReorderUserMembership(getIndex?: () => string) { + const { userMemberships } = useStores(); + const user = useCurrentUser(); + + return useDrop({ + accept: "userMembership", + drop: async (item: DragObject) => { + const userMembership = userMemberships.get(item.id); + void userMembership?.save({ + index: getIndex?.() ?? fractionalIndex(null, user.memberships[0].index), + }); + }, + collect: (monitor) => ({ + isOverCursor: !!monitor.isOver(), + isDragging: monitor.getItemType() === "userMembership", + }), + }); +} diff --git a/app/components/Sidebar/components/useStarLabelAndIcon.tsx b/app/components/Sidebar/components/useSidebarLabelAndIcon.tsx similarity index 60% rename from app/components/Sidebar/components/useStarLabelAndIcon.tsx rename to app/components/Sidebar/components/useSidebarLabelAndIcon.tsx index 1595d3022..c1afa20a7 100644 --- a/app/components/Sidebar/components/useStarLabelAndIcon.tsx +++ b/app/components/Sidebar/components/useSidebarLabelAndIcon.tsx @@ -1,25 +1,27 @@ -import { StarredIcon } from "outline-icons"; +import { DocumentIcon } from "outline-icons"; import * as React from "react"; -import { useTheme } from "styled-components"; -import Star from "~/models/Star"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import EmojiIcon from "~/components/Icons/EmojiIcon"; import useStores from "~/hooks/useStores"; -export function useStarLabelAndIcon({ documentId, collectionId }: Star) { +interface SidebarItem { + documentId?: string; + collectionId?: string; +} + +export function useSidebarLabelAndIcon( + { documentId, collectionId }: SidebarItem, + defaultIcon?: React.ReactNode +) { const { collections, documents } = useStores(); - const theme = useTheme(); + const icon = defaultIcon ?? ; if (documentId) { const document = documents.get(documentId); if (document) { return { label: document.titleWithDefault, - icon: document.emoji ? ( - - ) : ( - - ), + icon: document.emoji ? : icon, }; } } @@ -36,6 +38,6 @@ export function useStarLabelAndIcon({ documentId, collectionId }: Star) { return { label: "", - icon: , + icon, }; } diff --git a/app/components/Text.ts b/app/components/Text.ts index aa90a1da8..3436725e4 100644 --- a/app/components/Text.ts +++ b/app/components/Text.ts @@ -2,7 +2,7 @@ import styled, { css } from "styled-components"; type Props = { type?: "secondary" | "tertiary" | "danger"; - size?: "large" | "small" | "xsmall"; + size?: "xlarge" | "large" | "medium" | "small" | "xsmall"; dir?: "ltr" | "rtl" | "auto"; selectable?: boolean; weight?: "bold" | "normal"; @@ -24,8 +24,12 @@ const Text = styled.p` ? props.theme.brand.red : props.theme.text}; font-size: ${(props) => - props.size === "large" + props.size === "xlarge" + ? "26px" + : props.size === "large" ? "18px" + : props.size === "medium" + ? "16px" : props.size === "small" ? "14px" : props.size === "xsmall" diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 4c36b8c1e..b5bf16025 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -19,6 +19,7 @@ import Star from "~/models/Star"; import Subscription from "~/models/Subscription"; import Team from "~/models/Team"; import User from "~/models/User"; +import UserMembership from "~/models/UserMembership"; import withStores from "~/components/withStores"; import { PartialWithId, @@ -87,6 +88,7 @@ class WebsocketProvider extends React.Component { pins, stars, memberships, + userMemberships, policies, comments, subscriptions, @@ -235,6 +237,10 @@ class WebsocketProvider extends React.Component { const collection = collections.get(event.collectionId); collection?.removeDocument(event.id); } + + userMemberships.orderedData + .filter((m) => m.documentId === event.id) + .forEach((m) => userMemberships.remove(m.id)); }) ); @@ -245,6 +251,44 @@ class WebsocketProvider extends React.Component { } ); + // received when a user is given access to a document + this.socket.on( + "documents.add_user", + (event: PartialWithId) => { + userMemberships.add(event); + } + ); + + this.socket.on( + "documents.remove_user", + (event: PartialWithId) => { + if (event.userId) { + const userMembership = userMemberships.get(event.id); + + // TODO: Possibly replace this with a one-to-many relation decorator. + if (userMembership) { + userMemberships + .filter({ + userId: event.userId, + sourceId: userMembership.id, + }) + .forEach((m) => { + m.documentId && documents.remove(m.documentId); + }); + } + + userMemberships.removeAll({ + userId: event.userId, + documentId: event.documentId, + }); + } + + if (event.documentId && event.userId === auth.user?.id) { + documents.remove(event.documentId); + } + } + ); + this.socket.on("comments.create", (event: PartialWithId) => { comments.add(event); }); @@ -367,7 +411,7 @@ class WebsocketProvider extends React.Component { this.socket.on( "collections.add_user", async (event: WebsocketCollectionUserEvent) => { - if (auth.user && event.userId === auth.user.id) { + if (event.userId === auth.user?.id) { await collections.fetch(event.collectionId, { force: true, }); @@ -386,7 +430,7 @@ class WebsocketProvider extends React.Component { this.socket.on( "collections.remove_user", async (event: WebsocketCollectionUserEvent) => { - if (auth.user && event.userId === auth.user.id) { + if (event.userId === auth.user?.id) { // check if we still have access to the collection try { await collections.fetch(event.collectionId, { @@ -466,12 +510,19 @@ class WebsocketProvider extends React.Component { ); this.socket.on("users.demote", async (event: PartialWithId) => { - if (auth.user && event.id === auth.user.id) { + if (event.id === auth.user?.id) { documents.all.forEach((document) => policies.remove(document.id)); await collections.fetchAll(); } }); + this.socket.on( + "userMemberships.update", + async (event: PartialWithId) => { + userMemberships.add(event); + } + ); + // received a message from the API server that we should request // to join a specific room. Forward that to the ws server. this.socket.on("join", (event) => { diff --git a/app/menus/MemberMenu.tsx b/app/menus/MemberMenu.tsx index de2223478..d95f7e464 100644 --- a/app/menus/MemberMenu.tsx +++ b/app/menus/MemberMenu.tsx @@ -16,7 +16,7 @@ function MemberMenu({ user, onRemove }: Props) { const { t } = useTranslation(); const currentUser = useCurrentUser(); const menu = useMenuState({ - modal: true, + modal: false, }); return ( diff --git a/app/models/Collection.ts b/app/models/Collection.ts index a5fd36fcc..9be651e79 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -45,7 +45,7 @@ export default class Collection extends ParanoidModel { @Field @observable - permission: CollectionPermission | void; + permission?: CollectionPermission; @Field @observable diff --git a/app/models/Document.ts b/app/models/Document.ts index 4d3b79865..3dc6df56d 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -154,6 +154,12 @@ export default class Document extends ParanoidModel { @observable revision: number; + /** + * Whether this document is contained in a collection that has been deleted. + */ + @observable + isCollectionDeleted: boolean; + /** * Returns the direction of the document text, either "rtl" or "ltr" */ @@ -227,6 +233,18 @@ export default class Document extends ParanoidModel { ); } + /** + * Returns users that have been individually given access to the document. + * + * @returns users that have been individually given access to the document + */ + @computed + get members(): User[] { + return this.store.rootStore.userMemberships.orderedData + .filter((m) => m.documentId === this.id) + .map((m) => m.user); + } + @computed get isArchived(): boolean { return !!this.archivedAt; diff --git a/app/models/User.ts b/app/models/User.ts index 2344d9ea6..6ae9274fd 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -12,6 +12,8 @@ import { } from "@shared/types"; import type { NotificationSettings } from "@shared/types"; import { client } from "~/utils/ApiClient"; +import Document from "./Document"; +import UserMembership from "./UserMembership"; import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; @@ -109,6 +111,18 @@ class User extends ParanoidModel { ); } + @computed + get memberships(): UserMembership[] { + return this.store.rootStore.userMemberships.orderedData + .filter( + (m) => m.userId === this.id && m.sourceId === null && m.documentId + ) + .filter((m) => { + const document = this.store.rootStore.documents.get(m.documentId!); + return !document?.collection; + }); + } + /** * Returns the current preference for the given notification event type taking * into account the default system value. @@ -172,6 +186,12 @@ class User extends ParanoidModel { [key]: value, }; } + + getMembership(document: Document) { + return this.store.rootStore.userMemberships.orderedData.find( + (m) => m.documentId === document.id && m.userId === this.id + ); + } } export default User; diff --git a/app/models/UserMembership.ts b/app/models/UserMembership.ts new file mode 100644 index 000000000..ae50e6627 --- /dev/null +++ b/app/models/UserMembership.ts @@ -0,0 +1,75 @@ +import { observable } from "mobx"; +import { DocumentPermission } from "@shared/types"; +import type UserMembershipsStore from "~/stores/UserMembershipsStore"; +import Document from "./Document"; +import User from "./User"; +import Model from "./base/Model"; +import Field from "./decorators/Field"; +import Relation from "./decorators/Relation"; + +class UserMembership extends Model { + static modelName = "UserMembership"; + + /** The sort order of the membership (In users sidebar) */ + @Field + @observable + index: string; + + /** The permission level granted to the user. */ + @observable + permission: DocumentPermission; + + /** The document ID that this permission grants the user access to. */ + documentId?: string; + + /** The document that this permission grants the user access to. */ + @Relation(() => Document, { onDelete: "cascade" }) + document?: Document; + + /** The source ID points to the root permission from which this permission inherits */ + sourceId?: string; + + /** The source points to the root permission from which this permission inherits */ + @Relation(() => UserMembership, { onDelete: "cascade" }) + source?: UserMembership; + + /** The user ID that this permission is granted to. */ + userId: string; + + /** The user that this permission is granted to. */ + @Relation(() => User, { onDelete: "cascade" }) + user: User; + + /** The user that created this permission. */ + @Relation(() => User, { onDelete: "null" }) + createdBy: User; + + /** The user ID that created this permission. */ + createdById: string; + + store: UserMembershipsStore; + + /** + * Returns the next membership for the same user in the list, or undefined if this is the last. + */ + next(): UserMembership | undefined { + const memberships = this.store.filter({ + userId: this.userId, + }); + const index = memberships.indexOf(this); + return memberships[index + 1]; + } + + /** + * Returns the previous membership for the same user in the list, or undefined if this is the first. + */ + previous(): UserMembership | undefined { + const memberships = this.store.filter({ + userId: this.userId, + }); + const index = memberships.indexOf(this); + return memberships[index + 1]; + } +} + +export default UserMembership; diff --git a/app/scenes/CollectionPermissions/components/CollectionGroupMemberListItem.tsx b/app/scenes/CollectionPermissions/components/CollectionGroupMemberListItem.tsx index 1e8dc1ffc..55c118ea4 100644 --- a/app/scenes/CollectionPermissions/components/CollectionGroupMemberListItem.tsx +++ b/app/scenes/CollectionPermissions/components/CollectionGroupMemberListItem.tsx @@ -1,10 +1,11 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import { CollectionPermission } from "@shared/types"; import CollectionGroupMembership from "~/models/CollectionGroupMembership"; import Group from "~/models/Group"; import GroupListItem from "~/components/GroupListItem"; +import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu"; -import InputMemberPermissionSelect from "./InputMemberPermissionSelect"; type Props = { group: Group; @@ -18,27 +19,41 @@ const CollectionGroupMemberListItem = ({ collectionGroupMembership, onUpdate, onRemove, -}: Props) => ( - ( - <> - - - - )} - /> -); +}: Props) => { + const { t } = useTranslation(); + + return ( + ( + <> + + + + )} + /> + ); +}; export default CollectionGroupMemberListItem; diff --git a/app/scenes/CollectionPermissions/components/MemberListItem.tsx b/app/scenes/CollectionPermissions/components/MemberListItem.tsx index fcecd495b..e453b2d30 100644 --- a/app/scenes/CollectionPermissions/components/MemberListItem.tsx +++ b/app/scenes/CollectionPermissions/components/MemberListItem.tsx @@ -4,18 +4,19 @@ import { Trans, useTranslation } from "react-i18next"; import { CollectionPermission } from "@shared/types"; import Membership from "~/models/Membership"; import User from "~/models/User"; +import UserMembership from "~/models/UserMembership"; import Avatar from "~/components/Avatar"; import Badge from "~/components/Badge"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; +import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import ListItem from "~/components/List/Item"; import Time from "~/components/Time"; import MemberMenu from "~/menus/MemberMenu"; -import InputMemberPermissionSelect from "./InputMemberPermissionSelect"; type Props = { user: User; - membership?: Membership | undefined; + membership?: Membership | UserMembership | undefined; canEdit: boolean; onAdd?: () => void; onRemove?: () => void; @@ -53,7 +54,21 @@ const MemberListItem = ({ {onUpdate && ( diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index 1bd0c0e1c..747bc46aa 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; -import { RouteComponentProps, useLocation, Redirect } from "react-router-dom"; +import { RouteComponentProps, useLocation } from "react-router-dom"; import styled, { ThemeProvider } from "styled-components"; import { setCookie } from "tiny-cookie"; import { s } from "@shared/styles"; @@ -19,7 +19,6 @@ import Text from "~/components/Text"; import env from "~/env"; import useBuildTheme from "~/hooks/useBuildTheme"; import useCurrentUser from "~/hooks/useCurrentUser"; -import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { AuthorizationError, OfflineError } from "~/utils/errors"; import isCloudHosted from "~/utils/isCloudHosted"; @@ -102,7 +101,6 @@ function SharedDocumentScene(props: Props) { ) ? (searchParams.get("theme") as Theme) : undefined; - const can = usePolicy(response?.document); const theme = useBuildTheme(response?.team?.customTheme, themeOverride); React.useEffect(() => { @@ -167,10 +165,6 @@ function SharedDocumentScene(props: Props) { return ; } - if (response && searchParams.get("edit") === "true" && can.update) { - return ; - } - return ( <> diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx index 30502751f..8baf76a21 100644 --- a/app/scenes/Document/components/DocumentTitle.tsx +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -337,6 +337,7 @@ const Title = styled(ContentEditable)` &::placeholder { color: ${s("placeholder")}; -webkit-text-fill-color: ${s("placeholder")}; + opacity: 1; } &:focus-within, diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index c2ab8dd8b..f92de0aa1 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -257,16 +257,11 @@ function DocumentHeader({ /> )} - {!isEditing && - !isDeleted && - !isRevision && - !isTemplate && - !isMobile && - document.collectionId && ( - - - - )} + {!isEditing && !isRevision && !isMobile && can.update && ( + + + + )} {(isEditing || isTemplate) && ( )} - - - + {can.publish && ( + + + + )} {!isDeleted && } {(props) => ( - - Anyone with the link
- can view this document - - ) : ( - "" - ) - } - delay={500} - placement="bottom" + -
+ {t("Share")} {domain && <>· {domain}} + )}
- + void; - /** Whether the popover is visible. */ - visible: boolean; -}; - -function SharePopover({ - document, - share, - sharedParent, - hideTitle, - onRequestClose, - visible, -}: Props) { - const team = useCurrentTeam(); - const { t } = useTranslation(); - const { shares, collections } = useStores(); - const [expandedOptions, setExpandedOptions] = React.useState(false); - const [isEditMode, setIsEditMode] = React.useState(false); - const [slugValidationError, setSlugValidationError] = React.useState(""); - const [urlSlug, setUrlSlug] = React.useState(""); - const timeout = React.useRef>(); - const buttonRef = React.useRef(null); - const can = usePolicy(share); - const documentAbilities = usePolicy(document); - const collection = document.collectionId - ? collections.get(document.collectionId) - : undefined; - const canPublish = - can.update && - !document.isTemplate && - team.sharing && - collection?.sharing && - documentAbilities.share; - const isPubliclyShared = - team.sharing && - ((share && share.published) || - (sharedParent && sharedParent.published && !document.isDraft)); - - React.useEffect(() => { - if (!visible && expandedOptions) { - setExpandedOptions(false); - } - }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps - useKeyDown("Escape", onRequestClose); - - React.useEffect(() => { - if (visible) { - void document.share(); - buttonRef.current?.focus(); - } - - return () => (timeout.current ? clearTimeout(timeout.current) : undefined); - }, [document, visible]); - - React.useEffect(() => { - if (!visible) { - setUrlSlug(share?.urlId || ""); - setSlugValidationError(""); - } - }, [share, visible]); - - const handlePublishedChange = React.useCallback( - async (event) => { - const share = shares.getByDocumentId(document.id); - invariant(share, "Share must exist"); - - try { - await share.save({ - published: event.currentTarget.checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [document.id, shares] - ); - - const handleChildDocumentsChange = React.useCallback( - async (event) => { - const share = shares.getByDocumentId(document.id); - invariant(share, "Share must exist"); - - try { - await share.save({ - includeChildDocuments: event.currentTarget.checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [document.id, shares] - ); - - const handleCopied = React.useCallback(() => { - timeout.current = setTimeout(() => { - onRequestClose(); - toast.message(t("Share link copied")); - }, 250); - }, [t, onRequestClose]); - - const handleUrlSlugChange = React.useMemo( - () => - debounce(async (ev) => { - const share = shares.getByDocumentId(document.id); - invariant(share, "Share must exist"); - - const val = ev.target.value; - setUrlSlug(val); - if (val && !SHARE_URL_SLUG_REGEX.test(val)) { - setSlugValidationError( - t("Only lowercase letters, digits and dashes allowed") - ); - } else { - setSlugValidationError(""); - if (share.urlId !== val) { - try { - await share.save({ - urlId: isEmpty(val) ? null : val, - }); - } catch (err) { - if (err.message.includes("must be unique")) { - setSlugValidationError( - t("Sorry, this link has already been used") - ); - } - } - } - } - }, 500), - [t, document.id, shares] - ); - - const PublishToInternet = ({ canPublish }: { canPublish: boolean }) => { - if (!canPublish) { - return ( - - {t("Only members with permission can view")} - - ); - } - return ( - - - - - {share?.published - ? t("Anyone with the link can view this document") - : t("Only members with permission can view")} - {share?.lastAccessedAt && ( - <> - .{" "} - {t("The shared link was last accessed {{ timeAgo }}.", { - timeAgo: dateToRelative(Date.parse(share?.lastAccessedAt), { - addSuffix: true, - locale, - }), - })} - - )} - - - - ); - }; - - const userLocale = useUserLocale(); - const locale = userLocale ? dateLocale(userLocale) : undefined; - let shareUrl = sharedParent?.url - ? `${sharedParent.url}${document.url}` - : share?.url ?? ""; - if (isEditMode) { - shareUrl += "?edit=true"; - } - - const url = shareUrl.replace(/https?:\/\//, ""); - const documentTitle = sharedParent?.documentTitle; - - return ( - <> - {!hideTitle && ( - - {isPubliclyShared ? ( - - ) : ( - - )} - {t("Share this document")} - - )} - - {sharedParent && !document.isDraft && ( - - - - This document is shared because the parent{" "} - - {documentTitle} - {" "} - is publicly shared. - - - - )} - - {canPublish && !sharedParent?.published && ( - - )} - - {canPublish && share?.published && !document.isDraft && ( - - - - - {share.includeChildDocuments - ? t("Nested documents are publicly available") - : t("Nested documents are not shared")} - . - - - - )} - - {expandedOptions && ( - <> - {canPublish && sharedParent?.published && ( - <> - - - - )} - - - - setIsEditMode(checked) - } - checked={isEditMode} - disabled={!share} - /> - - - {isEditMode - ? t( - "Users with edit permission will be redirected to the main app" - ) - : t("All users see the same publicly shared view")} - . - - - - - - - {!slugValidationError && urlSlug && ( - - - The document will be accessible at{" "} - - {{ url }} - - - - )} - - - )} - - - {expandedOptions || !canPublish ? ( - - ) : ( - } - onClick={() => setExpandedOptions(true)} - neutral - borderOnHover - > - {t("More options")} - - )} - - - - - - ); -} - -const StyledLink = styled(Link)` - color: ${s("textSecondary")}; - text-decoration: underline; -`; - -const Heading = styled.h2` - display: flex; - align-items: center; - margin-top: 12px; - gap: 8px; - - /* accounts for icon padding */ - margin-left: -4px; -`; - -const SwitchWrapper = styled.div` - margin: 20px 0; -`; - -const NoticeWrapper = styled.div` - margin: 20px 0; -`; - -const MoreOptionsButton = styled(Button)` - background: none; - font-size: 14px; - color: ${s("textTertiary")}; - margin-left: -8px; -`; - -const Separator = styled.div` - height: 1px; - width: 100%; - background-color: ${s("divider")}; -`; - -const SwitchLabel = styled(Flex)` - svg { - flex-shrink: 0; - } -`; - -const SwitchText = styled(Text)` - margin: 0; - font-size: 15px; -`; - -const DocumentLinkPreview = styled(StyledText)` - margin-top: -12px; -`; - -export default observer(SharePopover); diff --git a/app/stores/MembershipsStore.ts b/app/stores/MembershipsStore.ts index d532426b2..843524125 100644 --- a/app/stores/MembershipsStore.ts +++ b/app/stores/MembershipsStore.ts @@ -16,7 +16,7 @@ export default class MembershipsStore extends Store { @action fetchPage = async ( - params: PaginationParams | undefined + params: (PaginationParams & { id?: string }) | undefined ): Promise => { this.isFetching = true; diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index 9b46d7afb..0cc1a3216 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -25,6 +25,7 @@ import SharesStore from "./SharesStore"; import StarsStore from "./StarsStore"; import SubscriptionsStore from "./SubscriptionsStore"; import UiStore from "./UiStore"; +import UserMembershipsStore from "./UserMembershipsStore"; import UsersStore from "./UsersStore"; import ViewsStore from "./ViewsStore"; import WebhookSubscriptionsStore from "./WebhookSubscriptionStore"; @@ -58,6 +59,7 @@ export default class RootStore { views: ViewsStore; fileOperations: FileOperationsStore; webhookSubscriptions: WebhookSubscriptionsStore; + userMemberships: UserMembershipsStore; constructor() { // Models @@ -84,6 +86,7 @@ export default class RootStore { this.registerStore(ViewsStore); this.registerStore(FileOperationsStore); this.registerStore(WebhookSubscriptionsStore); + this.registerStore(UserMembershipsStore); // Non-models this.registerStore(DocumentPresenceStore, "presence"); diff --git a/app/stores/StarsStore.ts b/app/stores/StarsStore.ts index 6c9cfaa65..9af8448f4 100644 --- a/app/stores/StarsStore.ts +++ b/app/stores/StarsStore.ts @@ -21,14 +21,13 @@ export default class StarsStore extends Store { const res = await client.post(`/stars.list`, params); invariant(res?.data, "Data not available"); - let models: Star[] = []; - runInAction(`StarsStore#fetchPage`, () => { + return runInAction(`StarsStore#fetchPage`, () => { res.data.documents.forEach(this.rootStore.documents.add); - models = res.data.stars.map(this.add); + const models = res.data.stars.map(this.add); this.addPolicies(res.policies); this.isLoaded = true; + return models; }); - return models; } finally { this.isFetching = false; } diff --git a/app/stores/UserMembershipsStore.ts b/app/stores/UserMembershipsStore.ts new file mode 100644 index 000000000..ea8281bb5 --- /dev/null +++ b/app/stores/UserMembershipsStore.ts @@ -0,0 +1,104 @@ +import invariant from "invariant"; +import { action, runInAction, computed } from "mobx"; +import UserMembership from "~/models/UserMembership"; +import { PaginationParams } from "~/types"; +import { client } from "~/utils/ApiClient"; +import RootStore from "./RootStore"; +import Store, { PAGINATION_SYMBOL, RPCAction } from "./base/Store"; + +export default class UserMembershipsStore extends Store { + actions = [ + RPCAction.List, + RPCAction.Create, + RPCAction.Delete, + RPCAction.Update, + ]; + + constructor(rootStore: RootStore) { + super(rootStore, UserMembership); + } + + @action + fetchPage = async ( + params?: PaginationParams | undefined + ): Promise => { + this.isFetching = true; + + try { + const res = await client.post(`/userMemberships.list`, params); + invariant(res?.data, "Data not available"); + + return runInAction(`UserMembershipsStore#fetchPage`, () => { + res.data.documents.forEach(this.rootStore.documents.add); + this.addPolicies(res.policies); + this.isLoaded = true; + return res.data.memberships.map(this.add); + }); + } finally { + this.isFetching = false; + } + }; + + @action + fetchDocumentMemberships = async ( + params: (PaginationParams & { id: string }) | undefined + ): Promise => { + this.isFetching = true; + + try { + const res = await client.post(`/documents.memberships`, params); + invariant(res?.data, "Data not available"); + + return runInAction(`MembershipsStore#fetchDocmentMemberships`, () => { + res.data.users.forEach(this.rootStore.users.add); + + const response = res.data.memberships.map(this.add); + this.isLoaded = true; + + response[PAGINATION_SYMBOL] = res.pagination; + return response; + }); + } finally { + this.isFetching = false; + } + }; + + @action + async create({ documentId, userId, permission }: Partial) { + const res = await client.post("/documents.add_user", { + id: documentId, + userId, + permission, + }); + + return runInAction(`UserMembershipsStore#create`, () => { + invariant(res?.data, "Membership data should be available"); + res.data.users.forEach(this.rootStore.users.add); + + const memberships = res.data.memberships.map(this.add); + return memberships[0]; + }); + } + + @action + async delete({ documentId, userId }: UserMembership) { + await client.post("/documents.remove_user", { + id: documentId, + userId, + }); + this.removeAll({ userId, documentId }); + } + + @computed + get orderedData(): UserMembership[] { + const memberships = Array.from(this.data.values()); + + return memberships.sort((a, b) => { + if (a.index === b.index) { + return a.updatedAt > b.updatedAt ? -1 : 1; + } + + return a.index < b.index ? -1 : 1; + }); + } +} diff --git a/app/stores/UsersStore.ts b/app/stores/UsersStore.ts index 6aca78b50..fe330cb0a 100644 --- a/app/stores/UsersStore.ts +++ b/app/stores/UsersStore.ts @@ -1,4 +1,5 @@ import invariant from "invariant"; +import differenceWith from "lodash/differenceWith"; import filter from "lodash/filter"; import orderBy from "lodash/orderBy"; import { observable, computed, action, runInAction } from "mobx"; @@ -249,6 +250,18 @@ export default class UsersStore extends Store { } }; + notInDocument = (documentId: string, query = "") => { + const document = this.rootStore.documents.get(documentId); + const teamMembers = this.activeOrInvited; + const documentMembers = document?.members ?? []; + const users = differenceWith( + teamMembers, + documentMembers, + (teamMember, documentMember) => teamMember.id === documentMember.id + ); + return queriedUsers(users, query); + }; + notInCollection = (collectionId: string, query = "") => { const memberships = filter( this.rootStore.memberships.orderedData, diff --git a/app/types.ts b/app/types.ts index 8000bea6e..b9f59c6de 100644 --- a/app/types.ts +++ b/app/types.ts @@ -1,12 +1,17 @@ /* eslint-disable @typescript-eslint/ban-types */ import { Location, LocationDescriptor } from "history"; import { TFunction } from "i18next"; -import { JSONValue } from "@shared/types"; +import { + JSONValue, + CollectionPermission, + DocumentPermission, +} from "@shared/types"; import RootStore from "~/stores/RootStore"; import Document from "./models/Document"; import FileOperation from "./models/FileOperation"; import Pin from "./models/Pin"; import Star from "./models/Star"; +import UserMembership from "./models/UserMembership"; export type PartialWithId = Partial & { id: string }; @@ -183,6 +188,11 @@ export type WebsocketCollectionUserEvent = { userId: string; }; +export type WebsocketDocumentUserEvent = { + documentId: string; + userId: string; +}; + export type WebsocketCollectionUpdateIndexEvent = { collectionId: string; index: string; @@ -192,6 +202,7 @@ export type WebsocketEvent = | PartialWithId | PartialWithId | PartialWithId + | PartialWithId | WebsocketCollectionUserEvent | WebsocketCollectionUpdateIndexEvent | WebsocketEntityDeletedEvent @@ -201,6 +212,13 @@ export type AwarenessChangeEvent = { states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[]; }; +export const EmptySelectValue = "__empty__"; + +export type Permission = { + label: string; + value: CollectionPermission | DocumentPermission | typeof EmptySelectValue; +}; + // TODO: Can we make this type driven by the @Field decorator export type Properties = { [Property in keyof C as C[Property] extends JSONValue diff --git a/package.json b/package.json index 2b5f7d292..86c307fba 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "natural-sort": "^1.0.0", "node-fetch": "2.7.0", "nodemailer": "^6.9.4", - "outline-icons": "^2.7.0", + "outline-icons": "^3.0.0", "oy-vey": "^0.12.1", "passport": "^0.6.0", "passport-google-oauth2": "^0.2.0", diff --git a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx index 1bc0e5f2b..20514a9a0 100644 --- a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx +++ b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx @@ -39,6 +39,8 @@ const WEBHOOK_EVENTS = { "documents.move", "documents.update", "documents.title_change", + "documents.add_user", + "documents.remove_user", ], collections: [ "collections.create", diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index aab3c27fd..5d8856970 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -18,7 +18,7 @@ import { Revision, View, Share, - UserPermission, + UserMembership, GroupPermission, GroupUser, Comment, @@ -48,6 +48,7 @@ import { CollectionUserEvent, CommentEvent, DocumentEvent, + DocumentUserEvent, Event, FileOperationEvent, GroupEvent, @@ -132,6 +133,10 @@ export default class DeliverWebhookTask extends BaseTask { case "documents.title_change": await this.handleDocumentEvent(subscription, event); return; + case "documents.add_user": + case "documents.remove_user": + await this.handleDocumentUserEvent(subscription, event); + return; case "documents.update.delayed": case "documents.update.debounced": // Ignored @@ -207,6 +212,9 @@ export default class DeliverWebhookTask extends BaseTask { case "views.create": await this.handleViewEvent(subscription, event); return; + case "userMemberships.update": + // Ignored + return; default: assertUnreachable(event); } @@ -427,7 +435,7 @@ export default class DeliverWebhookTask extends BaseTask { subscription: WebhookSubscription, event: CollectionUserEvent ): Promise { - const model = await UserPermission.scope([ + const model = await UserMembership.scope([ "withUser", "withCollection", ]).findOne({ @@ -513,6 +521,33 @@ export default class DeliverWebhookTask extends BaseTask { }); } + private async handleDocumentUserEvent( + subscription: WebhookSubscription, + event: DocumentUserEvent + ): Promise { + const model = await UserMembership.scope([ + "withUser", + "withDocument", + ]).findOne({ + where: { + documentId: event.documentId, + userId: event.userId, + }, + paranoid: false, + }); + + await this.sendWebhook({ + event, + subscription, + payload: { + id: event.modelId, + model: model && presentMembership(model), + document: model && (await presentDocument(model.document!)), + user: model && presentUser(model.user), + }, + }); + } + private async handleRevisionEvent( subscription: WebhookSubscription, event: RevisionEvent diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index 8424d0a25..fd9e0eeb0 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -156,7 +156,10 @@ export default async function documentCreator({ // reload to get all of the data needed to present (user, collection etc) // we need to specify publishedAt to bypass default scope that only returns // published documents - return await Document.findOne({ + return await Document.scope([ + "withDrafts", + { method: ["withMembership", user.id] }, + ]).findOne({ where: { id: document.id, publishedAt: document.publishedAt, diff --git a/server/commands/documentMover.ts b/server/commands/documentMover.ts index f2ae5fb8f..dc9603abc 100644 --- a/server/commands/documentMover.ts +++ b/server/commands/documentMover.ts @@ -2,7 +2,14 @@ import invariant from "invariant"; import { Transaction } from "sequelize"; import { ValidationError } from "@server/errors"; import { traceFunction } from "@server/logging/tracing"; -import { User, Document, Collection, Pin, Event } from "@server/models"; +import { + User, + Document, + Collection, + Pin, + Event, + UserMembership, +} from "@server/models"; import pinDestroyer from "./pinDestroyer"; type Props = { @@ -224,6 +231,24 @@ async function documentMover({ await document.save({ transaction }); result.documents.push(document); + // If there are any sourced permissions for this document, we need to go to the source + // permission and recalculate + const [documentPermissions, parentDocumentPermissions] = await Promise.all([ + UserMembership.findRootMembershipsForDocument(document.id, undefined, { + transaction, + }), + parentDocumentId + ? UserMembership.findRootMembershipsForDocument( + parentDocumentId, + undefined, + { transaction } + ) + : [], + ]); + + await recalculatePermissions(documentPermissions, transaction); + await recalculatePermissions(parentDocumentPermissions, transaction); + await Event.create( { name: "documents.move", @@ -247,6 +272,15 @@ async function documentMover({ return result; } +async function recalculatePermissions( + permissions: UserMembership[], + transaction?: Transaction +) { + for (const permission of permissions) { + await UserMembership.createSourcedMemberships(permission, { transaction }); + } +} + export default traceFunction({ spanName: "documentMover", })(documentMover); diff --git a/server/commands/userDemoter.test.ts b/server/commands/userDemoter.test.ts index cea51a871..b91e6c44d 100644 --- a/server/commands/userDemoter.test.ts +++ b/server/commands/userDemoter.test.ts @@ -1,5 +1,5 @@ import { CollectionPermission, UserRole } from "@shared/types"; -import { UserPermission } from "@server/models"; +import { UserMembership } from "@server/models"; import { buildUser, buildAdmin, buildCollection } from "@server/test/factories"; import userDemoter from "./userDemoter"; @@ -11,7 +11,7 @@ describe("userDemoter", () => { const user = await buildUser({ teamId: admin.teamId }); const collection = await buildCollection({ teamId: admin.teamId }); - const membership = await UserPermission.create({ + const membership = await UserMembership.create({ createdById: admin.id, userId: user.id, collectionId: collection.id, diff --git a/server/migrations/20231103114720-add-column-index-to-user-permissions.js b/server/migrations/20231103114720-add-column-index-to-user-permissions.js new file mode 100644 index 000000000..b07ed0fc3 --- /dev/null +++ b/server/migrations/20231103114720-add-column-index-to-user-permissions.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("user_permissions", "index", { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("user_permissions", "index"); + }, +}; diff --git a/server/migrations/20240113143315-user-permission-source-id.js b/server/migrations/20240113143315-user-permission-source-id.js new file mode 100644 index 000000000..6815dd38d --- /dev/null +++ b/server/migrations/20240113143315-user-permission-source-id.js @@ -0,0 +1,33 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("user_permissions", "sourceId", { + type: Sequelize.UUID, + onDelete: "cascade", + references: { + model: "user_permissions", + }, + allowNull: true, + }); + + await queryInterface.removeConstraint("user_permissions", "user_permissions_documentId_fkey") + await queryInterface.changeColumn("user_permissions", "documentId", { + type: Sequelize.UUID, + onDelete: "cascade", + references: { + model: "documents", + }, + }); + }, + async down(queryInterface) { + await queryInterface.removeConstraint("user_permissions", "user_permissions_documentId_fkey") + await queryInterface.changeColumn("user_permissions", "documentId", { + type: Sequelize.UUID, + references: { + model: "documents", + }, + }); + await queryInterface.removeColumn("user_permissions", "sourceId"); + }, +}; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index a93b6301c..1158bb5c0 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -7,7 +7,6 @@ import randomstring from "randomstring"; import { Identifier, Transaction, - Op, FindOptions, NonNullFindOptions, InferAttributes, @@ -47,7 +46,7 @@ import GroupPermission from "./GroupPermission"; import GroupUser from "./GroupUser"; import Team from "./Team"; import User from "./User"; -import UserPermission from "./UserPermission"; +import UserMembership from "./UserMembership"; import ParanoidModel from "./base/ParanoidModel"; import Fix from "./decorators/Fix"; import IsHexColor from "./validators/IsHexColor"; @@ -58,23 +57,13 @@ import NotContainsUrl from "./validators/NotContainsUrl"; withAllMemberships: { include: [ { - model: UserPermission, + model: UserMembership, as: "memberships", - where: { - collectionId: { - [Op.ne]: null, - }, - }, required: false, }, { model: GroupPermission, as: "collectionGroupMemberships", - where: { - collectionId: { - [Op.ne]: null, - }, - }, required: false, // use of "separate" property: sequelize breaks when there are // nested "includes" with alternating values for "required" @@ -112,24 +101,16 @@ import NotContainsUrl from "./validators/NotContainsUrl"; withMembership: (userId: string) => ({ include: [ { - model: UserPermission, + model: UserMembership, as: "memberships", where: { userId, - collectionId: { - [Op.ne]: null, - }, }, required: false, }, { model: GroupPermission, as: "collectionGroupMemberships", - where: { - collectionId: { - [Op.ne]: null, - }, - }, required: false, // use of "separate" property: sequelize breaks when there are // nested "includes" with alternating values for "required" @@ -288,7 +269,7 @@ class Collection extends ParanoidModel< model: Collection, options: { transaction: Transaction } ) { - return UserPermission.findOrCreate({ + return UserMembership.findOrCreate({ where: { collectionId: model.id, userId: model.createdById, @@ -313,13 +294,13 @@ class Collection extends ParanoidModel< @HasMany(() => Document, "collectionId") documents: Document[]; - @HasMany(() => UserPermission, "collectionId") - memberships: UserPermission[]; + @HasMany(() => UserMembership, "collectionId") + memberships: UserMembership[]; @HasMany(() => GroupPermission, "collectionId") collectionGroupMemberships: GroupPermission[]; - @BelongsToMany(() => User, () => UserPermission) + @BelongsToMany(() => User, () => UserMembership) users: User[]; @BelongsToMany(() => Group, () => GroupPermission) diff --git a/server/models/Comment.ts b/server/models/Comment.ts index 4ac2af5cd..710df1aeb 100644 --- a/server/models/Comment.ts +++ b/server/models/Comment.ts @@ -5,7 +5,6 @@ import { ForeignKey, Column, Table, - Scopes, Length, DefaultScope, } from "sequelize-typescript"; @@ -26,17 +25,6 @@ import TextLength from "./validators/TextLength"; }, ], })) -@Scopes(() => ({ - withDocument: { - include: [ - { - model: Document, - as: "document", - required: true, - }, - ], - }, -})) @Table({ tableName: "comments", modelName: "comment" }) @Fix class Comment extends ParanoidModel< diff --git a/server/models/Document.ts b/server/models/Document.ts index fd2432127..15a12c2a7 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -39,6 +39,7 @@ import { IsNumeric, IsDate, AllowNull, + BelongsToMany, } from "sequelize-typescript"; import isUUID from "validator/lib/isUUID"; import type { @@ -58,6 +59,7 @@ import Revision from "./Revision"; import Star from "./Star"; import Team from "./Team"; import User from "./User"; +import UserMembership from "./UserMembership"; import View from "./View"; import ParanoidModel from "./base/ParanoidModel"; import Fix from "./decorators/Fix"; @@ -100,33 +102,20 @@ type AdditionalFindOptions = { }, })) @Scopes(() => ({ - withCollectionPermissions: (userId: string, paranoid = true) => { - if (userId) { - return { - include: [ - { - attributes: ["id", "permission", "sharing", "teamId", "deletedAt"], - model: Collection.scope({ + withCollectionPermissions: (userId: string, paranoid = true) => ({ + include: [ + { + attributes: ["id", "permission", "sharing", "teamId", "deletedAt"], + model: userId + ? Collection.scope({ method: ["withMembership", userId], - }), - as: "collection", - paranoid, - }, - ], - }; - } - - return { - include: [ - { - attributes: ["id", "permission", "sharing", "teamId", "deletedAt"], - model: Collection, - as: "collection", - paranoid, - }, - ], - }; - }, + }) + : Collection, + as: "collection", + paranoid, + }, + ], + }), withoutState: { attributes: { exclude: ["state"], @@ -189,6 +178,22 @@ type AdditionalFindOptions = { ], }; }, + withMembership: (userId: string) => { + if (!userId) { + return {}; + } + return { + include: [ + { + association: "memberships", + where: { + userId, + }, + required: false, + }, + ], + }; + }, })) @Table({ tableName: "documents", modelName: "document" }) @Fix @@ -501,10 +506,16 @@ class Document extends ParanoidModel< @BelongsTo(() => Collection, "collectionId") collection: Collection | null | undefined; + @BelongsToMany(() => User, () => UserMembership) + users: User[]; + @ForeignKey(() => Collection) @Column(DataType.UUID) collectionId?: string | null; + @HasMany(() => UserMembership) + memberships: UserMembership[]; + @HasMany(() => Revision) revisions: Revision[]; @@ -524,7 +535,15 @@ class Document extends ParanoidModel< const viewScope: Readonly = { method: ["withViews", userId], }; - return this.scope(["defaultScope", collectionScope, viewScope]); + const membershipScope: Readonly = { + method: ["withMembership", userId], + }; + return this.scope([ + "defaultScope", + collectionScope, + viewScope, + membershipScope, + ]); } /** @@ -564,6 +583,9 @@ class Document extends ParanoidModel< { method: ["withViews", userId], }, + { + method: ["withMembership", userId], + }, ]); if (isUUID(id)) { @@ -788,11 +810,53 @@ class Document extends ParanoidModel< } } + const parentDocumentPermissions = this.parentDocumentId + ? await UserMembership.findAll({ + where: { + documentId: this.parentDocumentId, + }, + transaction, + }) + : []; + + await Promise.all( + parentDocumentPermissions.map((permission) => + UserMembership.create( + { + documentId: this.id, + userId: permission.userId, + sourceId: permission.sourceId ?? permission.id, + permission: permission.permission, + createdById: permission.createdById, + }, + { + transaction, + } + ) + ) + ); + this.lastModifiedById = userId; this.publishedAt = new Date(); return this.save({ transaction }); }; + isCollectionDeleted = async () => { + if (this.deletedAt || this.archivedAt) { + if (this.collectionId) { + const collection = + this.collection ?? + (await Collection.findByPk(this.collectionId, { + attributes: ["deletedAt"], + paranoid: false, + })); + + return !!collection?.deletedAt; + } + } + return false; + }; + unpublish = async (userId: string) => { // If the document is already a draft then calling unpublish should act like // a regular save diff --git a/server/models/Event.ts b/server/models/Event.ts index a6742f15d..27b3deb0e 100644 --- a/server/models/Event.ts +++ b/server/models/Event.ts @@ -145,9 +145,12 @@ class Event extends IdModel< "documents.delete", "documents.permanent_delete", "documents.restore", + "documents.add_user", + "documents.remove_user", "revisions.create", "users.create", "users.demote", + "userMemberships.update", ]; static AUDIT_EVENTS: TEvent["name"][] = [ @@ -172,6 +175,8 @@ class Event extends IdModel< "documents.delete", "documents.permanent_delete", "documents.restore", + "documents.add_user", + "documents.remove_user", "groups.create", "groups.update", "groups.delete", diff --git a/server/models/Team.ts b/server/models/Team.ts index 28a584d9b..a438f22ec 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -295,7 +295,7 @@ class Team extends ParanoidModel< }); }; - public collectionIds = async function (this: Team, paranoid = true) { + public collectionIds = async function (paranoid = true) { const models = await Collection.findAll({ attributes: ["id"], where: { diff --git a/server/models/User.test.ts b/server/models/User.test.ts index c8e2b533e..80ab4bff1 100644 --- a/server/models/User.test.ts +++ b/server/models/User.test.ts @@ -1,7 +1,7 @@ import { faker } from "@faker-js/faker"; import { CollectionPermission } from "@shared/types"; import { buildUser, buildTeam, buildCollection } from "@server/test/factories"; -import UserPermission from "./UserPermission"; +import UserMembership from "./UserMembership"; beforeAll(() => { jest.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z")); @@ -113,7 +113,7 @@ describe("user model", () => { teamId: team.id, permission: null, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, diff --git a/server/models/User.ts b/server/models/User.ts index 1c6249c66..18b6c97ed 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -40,6 +40,7 @@ import { NotificationEventType, NotificationEventDefaults, UserRole, + DocumentPermission, } from "@shared/types"; import { stringToColor } from "@shared/utils/color"; import env from "@server/env"; @@ -52,7 +53,7 @@ import AuthenticationProvider from "./AuthenticationProvider"; import Collection from "./Collection"; import Team from "./Team"; import UserAuthentication from "./UserAuthentication"; -import UserPermission from "./UserPermission"; +import UserMembership from "./UserMembership"; import ParanoidModel from "./base/ParanoidModel"; import Encrypted, { setEncryptedColumn, @@ -255,6 +256,12 @@ class User extends ParanoidModel< : CollectionPermission.ReadWrite; } + get defaultDocumentPermission(): DocumentPermission { + return this.isViewer + ? DocumentPermission.Read + : DocumentPermission.ReadWrite; + } + /** * Returns a code that can be used to delete this user account. The code will * be rotated when the user signs out. @@ -559,7 +566,7 @@ class User extends ParanoidModel< }, options ); - await UserPermission.update( + await UserMembership.update( { permission: CollectionPermission.Read, }, diff --git a/server/models/UserPermission.test.ts b/server/models/UserMembership.test.ts similarity index 60% rename from server/models/UserPermission.test.ts rename to server/models/UserMembership.test.ts index eb20bf86c..2df076db9 100644 --- a/server/models/UserPermission.test.ts +++ b/server/models/UserMembership.test.ts @@ -1,28 +1,28 @@ import { buildCollection, buildUser } from "@server/test/factories"; -import UserPermission from "./UserPermission"; +import UserMembership from "./UserMembership"; -describe("UserPermission", () => { +describe("UserMembership", () => { describe("withCollection scope", () => { it("should return the collection", async () => { const collection = await buildCollection(); const user = await buildUser({ teamId: collection.teamId }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, userId: user.id, collectionId: collection.id, }); - const permission = await UserPermission.scope("withCollection").findOne({ + const membership = await UserMembership.scope("withCollection").findOne({ where: { userId: user.id, collectionId: collection.id, }, }); - expect(permission).toBeDefined(); - expect(permission?.collection).toBeDefined(); - expect(permission?.collection?.id).toEqual(collection.id); + expect(membership).toBeDefined(); + expect(membership?.collection).toBeDefined(); + expect(membership?.collection?.id).toEqual(collection.id); }); }); }); diff --git a/server/models/UserMembership.ts b/server/models/UserMembership.ts new file mode 100644 index 000000000..f5fc43571 --- /dev/null +++ b/server/models/UserMembership.ts @@ -0,0 +1,252 @@ +import { + InferAttributes, + InferCreationAttributes, + Op, + type SaveOptions, + type FindOptions, +} from "sequelize"; +import { + Column, + ForeignKey, + BelongsTo, + Default, + IsIn, + Table, + DataType, + Scopes, + AllowNull, + AfterCreate, + AfterUpdate, +} from "sequelize-typescript"; +import { CollectionPermission, DocumentPermission } from "@shared/types"; +import Collection from "./Collection"; +import Document from "./Document"; +import User from "./User"; +import IdModel from "./base/IdModel"; +import Fix from "./decorators/Fix"; + +@Scopes(() => ({ + withUser: { + include: [ + { + association: "user", + }, + ], + }, + withCollection: { + where: { + collectionId: { + [Op.ne]: null, + }, + }, + include: [ + { + association: "collection", + }, + ], + }, + withDocument: { + where: { + documentId: { + [Op.ne]: null, + }, + }, + include: [ + { + association: "document", + }, + ], + }, +})) +@Table({ tableName: "user_permissions", modelName: "user_permission" }) +@Fix +class UserMembership extends IdModel< + InferAttributes, + Partial> +> { + @Default(CollectionPermission.ReadWrite) + @IsIn([Object.values(CollectionPermission)]) + @Column(DataType.STRING) + permission: CollectionPermission | DocumentPermission; + + @AllowNull + @Column + index: string | null; + + // associations + + /** The collection that this permission grants the user access to. */ + @BelongsTo(() => Collection, "collectionId") + collection?: Collection | null; + + /** The collection ID that this permission grants the user access to. */ + @ForeignKey(() => Collection) + @Column(DataType.UUID) + collectionId?: string | null; + + /** The document that this permission grants the user access to. */ + @BelongsTo(() => Document, "documentId") + document?: Document | null; + + /** The document ID that this permission grants the user access to. */ + @ForeignKey(() => Document) + @Column(DataType.UUID) + documentId?: string | null; + + /** If this represents the permission on a child then this points to the permission on the root */ + @BelongsTo(() => UserMembership, "sourceId") + source?: UserMembership | null; + + /** If this represents the permission on a child then this points to the permission on the root */ + @ForeignKey(() => UserMembership) + @Column(DataType.UUID) + sourceId?: string | null; + + /** The user that this permission is granted to. */ + @BelongsTo(() => User, "userId") + user: User; + + /** The user ID that this permission is granted to. */ + @ForeignKey(() => User) + @Column(DataType.UUID) + userId: string; + + /** The user that created this permission. */ + @BelongsTo(() => User, "createdById") + createdBy: User; + + /** The user ID that created this permission. */ + @ForeignKey(() => User) + @Column(DataType.UUID) + createdById: string; + + /** + * Find the root membership for a document and (optionally) user. + * + * @param documentId The document ID to find the membership for. + * @param userId The user ID to find the membership for. + * @param options Additional options to pass to the query. + * @returns A promise that resolves to the root memberships for the document and user, or null. + */ + static async findRootMembershipsForDocument( + documentId: string, + userId?: string, + options?: FindOptions + ): Promise { + const memberships = await this.findAll({ + where: { + documentId, + ...(userId ? { userId } : {}), + }, + }); + + const rootMemberships = await Promise.all( + memberships.map((membership) => + membership?.sourceId + ? this.findByPk(membership.sourceId, options) + : membership + ) + ); + + return rootMemberships.filter(Boolean) as UserMembership[]; + } + + @AfterUpdate + static async updateSourcedMemberships( + model: UserMembership, + options: SaveOptions + ) { + if (model.sourceId || !model.documentId) { + return; + } + + const { transaction } = options; + + if (model.changed("permission")) { + await this.update( + { + permission: model.permission, + }, + { + where: { + sourceId: model.id, + }, + transaction, + } + ); + } + } + + @AfterCreate + static async createSourcedMemberships( + model: UserMembership, + options: SaveOptions + ) { + if (model.sourceId || !model.documentId) { + return; + } + + return this.recreateSourcedMemberships(model, options); + } + + /** + * Recreate all sourced permissions for a given permission. + */ + static async recreateSourcedMemberships( + model: UserMembership, + options: SaveOptions + ) { + if (!model.documentId) { + return; + } + const { transaction } = options; + + await this.destroy({ + where: { + sourceId: model.id, + }, + transaction, + }); + + const document = await Document.unscoped().findOne({ + attributes: ["id"], + where: { + id: model.documentId, + }, + transaction, + }); + if (!document) { + return; + } + + const childDocumentIds = await document.findAllChildDocumentIds( + { + publishedAt: { + [Op.ne]: null, + }, + }, + { + transaction, + } + ); + + for (const childDocumentId of childDocumentIds) { + await this.create( + { + documentId: childDocumentId, + userId: model.userId, + permission: model.permission, + sourceId: model.id, + createdById: model.createdById, + createdAt: model.createdAt, + updatedAt: model.updatedAt, + }, + { + transaction, + } + ); + } + } +} + +export default UserMembership; diff --git a/server/models/UserPermission.ts b/server/models/UserPermission.ts deleted file mode 100644 index 8576dae18..000000000 --- a/server/models/UserPermission.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { InferAttributes, InferCreationAttributes, Op } from "sequelize"; -import { - Column, - ForeignKey, - BelongsTo, - Default, - IsIn, - Table, - DataType, - Scopes, -} from "sequelize-typescript"; -import { CollectionPermission } from "@shared/types"; -import Collection from "./Collection"; -import Document from "./Document"; -import User from "./User"; -import IdModel from "./base/IdModel"; -import Fix from "./decorators/Fix"; - -@Scopes(() => ({ - withUser: { - include: [ - { - association: "user", - }, - ], - }, - withCollection: { - where: { - collectionId: { - [Op.ne]: null, - }, - }, - include: [ - { - association: "collection", - }, - ], - }, -})) -@Table({ tableName: "user_permissions", modelName: "user_permission" }) -@Fix -class UserPermission extends IdModel< - InferAttributes, - Partial> -> { - @Default(CollectionPermission.ReadWrite) - @IsIn([Object.values(CollectionPermission)]) - @Column(DataType.STRING) - permission: CollectionPermission; - - // associations - - @BelongsTo(() => Collection, "collectionId") - collection?: Collection | null; - - @ForeignKey(() => Collection) - @Column(DataType.UUID) - collectionId?: string | null; - - @BelongsTo(() => Document, "documentId") - document?: Document | null; - - @ForeignKey(() => Document) - @Column(DataType.UUID) - documentId?: string | null; - - @BelongsTo(() => User, "userId") - user: User; - - @ForeignKey(() => User) - @Column(DataType.UUID) - userId: string; - - @BelongsTo(() => User, "createdById") - createdBy: User; - - @ForeignKey(() => User) - @Column(DataType.UUID) - createdById: string; -} - -export default UserPermission; diff --git a/server/models/helpers/SearchHelper.test.ts b/server/models/helpers/SearchHelper.test.ts index b0349c729..ac5a58841 100644 --- a/server/models/helpers/SearchHelper.test.ts +++ b/server/models/helpers/SearchHelper.test.ts @@ -1,3 +1,4 @@ +import { DocumentPermission } from "@shared/types"; import SearchHelper from "@server/models/helpers/SearchHelper"; import { buildDocument, @@ -7,9 +8,11 @@ import { buildUser, buildShare, } from "@server/test/factories"; +import UserMembership from "../UserMembership"; -beforeEach(() => { +beforeEach(async () => { jest.resetAllMocks(); + await buildDocument(); }); describe("SearchHelper", () => { @@ -118,7 +121,7 @@ describe("SearchHelper", () => { title: "test number 2", }); const { totalCount } = await SearchHelper.searchForTeam(team, "test"); - expect(totalCount).toBe("2"); + expect(totalCount).toBe(2); }); test("should return the document when searched with their previous titles", async () => { @@ -137,7 +140,7 @@ describe("SearchHelper", () => { team, "test number" ); - expect(totalCount).toBe("1"); + expect(totalCount).toBe(1); }); test("should not return the document when searched with neither the titles nor the previous titles", async () => { @@ -156,7 +159,7 @@ describe("SearchHelper", () => { team, "title doesn't exist" ); - expect(totalCount).toBe("0"); + expect(totalCount).toBe(0); }); }); @@ -174,6 +177,13 @@ describe("SearchHelper", () => { collectionId: collection.id, title: "test", }); + await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + deletedAt: new Date(), + title: "test", + }); const { results } = await SearchHelper.searchForUser(user, "test"); expect(results.length).toBe(1); expect(results[0].document?.id).toBe(document.id); @@ -217,6 +227,27 @@ describe("SearchHelper", () => { expect(results.length).toBe(0); }); + test("should not include drafts with user permission", async () => { + const user = await buildUser(); + const draft = await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + await UserMembership.create({ + createdById: user.id, + documentId: draft.id, + userId: user.id, + permission: DocumentPermission.Read, + }); + + const { results } = await SearchHelper.searchForUser(user, "test", { + includeDrafts: false, + }); + expect(results.length).toBe(0); + }); + test("should include results from drafts as well", async () => { const user = await buildUser(); await buildDocument({ @@ -277,7 +308,7 @@ describe("SearchHelper", () => { title: "test number 2", }); const { totalCount } = await SearchHelper.searchForUser(user, "test"); - expect(totalCount).toBe("2"); + expect(totalCount).toBe(2); }); test("should return the document when searched with their previous titles", async () => { @@ -299,7 +330,7 @@ describe("SearchHelper", () => { user, "test number" ); - expect(totalCount).toBe("1"); + expect(totalCount).toBe(1); }); test("should not return the document when searched with neither the titles nor the previous titles", async () => { @@ -321,7 +352,7 @@ describe("SearchHelper", () => { user, "title doesn't exist" ); - expect(totalCount).toBe("0"); + expect(totalCount).toBe(0); }); }); diff --git a/server/models/helpers/SearchHelper.ts b/server/models/helpers/SearchHelper.ts index ed8cab5c4..48293ca4c 100644 --- a/server/models/helpers/SearchHelper.ts +++ b/server/models/helpers/SearchHelper.ts @@ -3,7 +3,7 @@ import invariant from "invariant"; import find from "lodash/find"; import map from "lodash/map"; import queryParser from "pg-tsquery"; -import { Op, QueryTypes, WhereOptions } from "sequelize"; +import { Op, Sequelize, WhereOptions } from "sequelize"; import { DateFilter } from "@shared/types"; import Collection from "@server/models/Collection"; import Document from "@server/models/Document"; @@ -48,7 +48,7 @@ type SearchOptions = { snippetMaxWords?: number; }; -type Results = { +type RankedDocument = Document & { searchRanking: number; searchContext: string; id: string; @@ -72,25 +72,7 @@ export default class SearchHelper { offset = 0, } = options; - // restrict to specific collection if provided - // enables search in private collections if specified - let collectionIds: string[]; - if (options.collectionId) { - collectionIds = [options.collectionId]; - } else { - collectionIds = await team.collectionIds(); - } - - // short circuit if no relevant collections - if (!collectionIds.length) { - return { - results: [], - totalCount: 0, - }; - } - - // restrict to documents in the tree of a shared document when one is provided - let documentIds: string[] | undefined; + const where = await this.buildWhere(team, options); if (options.share?.includeChildDocuments) { const sharedDocument = await options.share.$get("document"); @@ -101,57 +83,57 @@ export default class SearchHelper { [Op.is]: null, }, }); - documentIds = [sharedDocument.id, ...childDocumentIds]; + + where[Op.and].push({ + id: [sharedDocument.id, ...childDocumentIds], + }); } - const documentClause = documentIds ? `"id" IN(:documentIds) AND` : ""; + where[Op.and].push( + Sequelize.fn( + `"searchVector" @@ to_tsquery`, + "english", + Sequelize.literal(":query") + ) + ); - // Build the SQL query to get result documentIds, ranking, and search term context - const whereClause = ` - "searchVector" @@ to_tsquery('english', :query) AND - "teamId" = :teamId AND - "collectionId" IN(:collectionIds) AND - ${documentClause} - "deletedAt" IS NULL AND - "publishedAt" IS NOT NULL - `; - const selectSql = ` - SELECT - id, - ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking", - ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions) as "searchContext" - FROM documents - WHERE ${whereClause} - ORDER BY - "searchRanking" DESC, - "updatedAt" DESC - LIMIT :limit - OFFSET :offset; - `; - const countSql = ` - SELECT COUNT(id) - FROM documents - WHERE ${whereClause} - `; const queryReplacements = { - teamId: team.id, query: this.webSearchQuery(query), - collectionIds, - documentIds, headlineOptions: `MaxFragments=1, MinWords=${snippetMinWords}, MaxWords=${snippetMaxWords}`, }; - const resultsQuery = sequelize.query(selectSql, { - type: QueryTypes.SELECT, - replacements: { ...queryReplacements, limit, offset }, - }); - const countQuery = sequelize.query<{ count: number }>(countSql, { - type: QueryTypes.SELECT, + + const resultsQuery = Document.unscoped().findAll({ + attributes: [ + "id", + [ + Sequelize.literal( + `ts_rank("searchVector", to_tsquery('english', :query))` + ), + "searchRanking", + ], + [ + Sequelize.literal( + `ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions)` + ), + "searchContext", + ], + ], replacements: queryReplacements, - }); - const [results, [{ count }]] = await Promise.all([ - resultsQuery, - countQuery, - ]); + where, + order: [ + ["searchRanking", "DESC"], + ["updatedAt", "DESC"], + ], + limit, + offset, + }) as any as Promise; + + const countQuery = Document.unscoped().count({ + // @ts-expect-error Types are incorrect for count + replacements: queryReplacements, + where, + }) as any as Promise; + const [results, count] = await Promise.all([resultsQuery, countQuery]); // Final query to get associated document data const documents = await Document.findAll({ @@ -167,7 +149,7 @@ export default class SearchHelper { ], }); - return SearchHelper.buildResponse(results, documents, count); + return this.buildResponse(results, documents, count); } public static async searchTitlesForUser( @@ -176,88 +158,36 @@ export default class SearchHelper { options: SearchOptions = {} ): Promise { const { limit = 15, offset = 0 } = options; + const where = await this.buildWhere(user, options); - const where: WhereOptions = { - teamId: user.teamId, + where[Op.and].push({ title: { [Op.iLike]: `%${query}%`, }, - [Op.and]: [], - }; + }); - // Ensure we're filtering by the users accessible collections. If - // collectionId is passed as an option it is assumed that the authorization - // has already been done in the router - if (options.collectionId) { - where[Op.and].push({ - collectionId: options.collectionId, - }); - } else { - where[Op.and].push({ - [Op.or]: [ - { - collectionId: { - [Op.in]: await user.collectionIds(), - }, - }, - { - collectionId: { - [Op.is]: null, - }, - createdById: user.id, - }, - ], - }); - } - - if (options.dateFilter) { - where[Op.and].push({ - updatedAt: { - [Op.gt]: sequelize.literal( - `now() - interval '1 ${options.dateFilter}'` - ), + const include = [ + { + association: "memberships", + where: { + userId: user.id, }, - }); - } + required: false, + separate: false, + }, + { + model: User, + as: "createdBy", + paranoid: false, + }, + { + model: User, + as: "updatedBy", + paranoid: false, + }, + ]; - if (!options.includeArchived) { - where[Op.and].push({ - archivedAt: { - [Op.is]: null, - }, - }); - } - - if (options.includeDrafts) { - where[Op.and].push({ - [Op.or]: [ - { - publishedAt: { - [Op.ne]: null, - }, - }, - { - createdById: user.id, - }, - ], - }); - } else { - where[Op.and].push({ - publishedAt: { - [Op.ne]: null, - }, - }); - } - - if (options.collaboratorIds) { - where[Op.and].push({ - collaboratorIds: { - [Op.contains]: options.collaboratorIds, - }, - }); - } - - return await Document.scope([ + return Document.scope([ "withoutState", "withDrafts", { @@ -266,21 +196,14 @@ export default class SearchHelper { { method: ["withCollectionPermissions", user.id], }, + { + method: ["withMembership", user.id], + }, ]).findAll({ where, + subQuery: false, order: [["updatedAt", "DESC"]], - include: [ - { - model: User, - as: "createdBy", - paranoid: false, - }, - { - model: User, - as: "updatedBy", - paranoid: false, - }, - ], + include, offset, limit, }); @@ -297,90 +220,69 @@ export default class SearchHelper { limit = 15, offset = 0, } = options; - // Ensure we're filtering by the users accessible collections. If - // collectionId is passed as an option it is assumed that the authorization - // has already been done in the router - let collectionIds; - if (options.collectionId) { - collectionIds = [options.collectionId]; - } else { - collectionIds = await user.collectionIds(); - } + const where = await this.buildWhere(user, options); - let dateFilter; + where[Op.and].push( + Sequelize.fn( + `"searchVector" @@ to_tsquery`, + "english", + Sequelize.literal(":query") + ) + ); - if (options.dateFilter) { - dateFilter = `1 ${options.dateFilter}`; - } - - // Build the SQL query to get documentIds, ranking, and search term context - const whereClause = ` - "searchVector" @@ to_tsquery('english', :query) AND - "teamId" = :teamId AND - ${ - collectionIds.length - ? `( - "collectionId" IN(:collectionIds) OR - ("collectionId" IS NULL AND "createdById" = :userId) - ) AND` - : '"collectionId" IS NULL AND "createdById" = :userId AND' - } - ${ - options.dateFilter ? '"updatedAt" > now() - interval :dateFilter AND' : "" - } - ${ - options.collaboratorIds - ? '"collaboratorIds" @> ARRAY[:collaboratorIds]::uuid[] AND' - : "" - } - ${options.includeArchived ? "" : '"archivedAt" IS NULL AND'} - "deletedAt" IS NULL AND - ${ - options.includeDrafts - ? '("publishedAt" IS NOT NULL OR "createdById" = :userId)' - : '"publishedAt" IS NOT NULL' - } - `; - const selectSql = ` - SELECT - id, - ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking", - ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions) as "searchContext" - FROM documents - WHERE ${whereClause} - ORDER BY - "searchRanking" DESC, - "updatedAt" DESC - LIMIT :limit - OFFSET :offset; - `; - const countSql = ` - SELECT COUNT(id) - FROM documents - WHERE ${whereClause} - `; const queryReplacements = { - teamId: user.teamId, - userId: user.id, - collaboratorIds: options.collaboratorIds, query: this.webSearchQuery(query), - collectionIds, - dateFilter, headlineOptions: `MaxFragments=1, MinWords=${snippetMinWords}, MaxWords=${snippetMaxWords}`, }; - const resultsQuery = sequelize.query(selectSql, { - type: QueryTypes.SELECT, - replacements: { ...queryReplacements, limit, offset }, - }); - const countQuery = sequelize.query<{ count: number }>(countSql, { - type: QueryTypes.SELECT, + + const include = [ + { + association: "memberships", + where: { + userId: user.id, + }, + required: false, + separate: false, + }, + ]; + + const resultsQuery = Document.unscoped().findAll({ + attributes: [ + "id", + [ + Sequelize.literal( + `ts_rank("searchVector", to_tsquery('english', :query))` + ), + "searchRanking", + ], + [ + Sequelize.literal( + `ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions)` + ), + "searchContext", + ], + ], + subQuery: false, + include, replacements: queryReplacements, - }); - const [results, [{ count }]] = await Promise.all([ - resultsQuery, - countQuery, - ]); + where, + order: [ + ["searchRanking", "DESC"], + ["updatedAt", "DESC"], + ], + limit, + offset, + }) as any as Promise; + + const countQuery = Document.unscoped().count({ + // @ts-expect-error Types are incorrect for count + subQuery: false, + include, + replacements: queryReplacements, + where, + }) as any as Promise; + const [results, count] = await Promise.all([resultsQuery, countQuery]); // Final query to get associated document data const documents = await Document.scope([ @@ -392,6 +294,9 @@ export default class SearchHelper { { method: ["withCollectionPermissions", user.id], }, + { + method: ["withMembership", user.id], + }, ]).findAll({ where: { teamId: user.teamId, @@ -399,11 +304,91 @@ export default class SearchHelper { }, }); - return SearchHelper.buildResponse(results, documents, count); + return this.buildResponse(results, documents, count); + } + + private static async buildWhere(model: User | Team, options: SearchOptions) { + const teamId = model instanceof Team ? model.id : model.teamId; + const where: WhereOptions = { + teamId, + [Op.or]: [], + [Op.and]: [ + { + deletedAt: { + [Op.eq]: null, + }, + }, + ], + }; + + if (model instanceof User) { + where[Op.or].push({ "$memberships.id$": { [Op.ne]: null } }); + } + + // Ensure we're filtering by the users accessible collections. If + // collectionId is passed as an option it is assumed that the authorization + // has already been done in the router + const collectionIds = options.collectionId + ? [options.collectionId] + : await model.collectionIds(); + + if (collectionIds.length) { + where[Op.or].push({ collectionId: collectionIds }); + } + + if (options.dateFilter) { + where[Op.and].push({ + updatedAt: { + [Op.gt]: sequelize.literal( + `now() - interval '1 ${options.dateFilter}'` + ), + }, + }); + } + + if (options.collaboratorIds) { + where[Op.and].push({ + collaboratorIds: { + [Op.contains]: options.collaboratorIds, + }, + }); + } + + if (!options.includeArchived) { + where[Op.and].push({ + archivedAt: { + [Op.eq]: null, + }, + }); + } + + if (options.includeDrafts && model instanceof User) { + where[Op.and].push({ + [Op.or]: [ + { + publishedAt: { + [Op.ne]: null, + }, + }, + { + createdById: model.id, + }, + { "$memberships.id$": { [Op.ne]: null } }, + ], + }); + } else { + where[Op.and].push({ + publishedAt: { + [Op.ne]: null, + }, + }); + } + + return where; } private static buildResponse( - results: Results[], + results: RankedDocument[], documents: Document[], count: number ): SearchResponse { diff --git a/server/models/index.ts b/server/models/index.ts index 02d69a3b9..7ec3fc696 100644 --- a/server/models/index.ts +++ b/server/models/index.ts @@ -10,7 +10,7 @@ export { default as Collection } from "./Collection"; export { default as GroupPermission } from "./GroupPermission"; -export { default as UserPermission } from "./UserPermission"; +export { default as UserMembership } from "./UserMembership"; export { default as Comment } from "./Comment"; diff --git a/server/policies/collection.test.ts b/server/policies/collection.test.ts index d2474df5e..451b45eef 100644 --- a/server/policies/collection.test.ts +++ b/server/policies/collection.test.ts @@ -1,5 +1,5 @@ import { CollectionPermission } from "@shared/types"; -import { UserPermission, Collection } from "@server/models"; +import { UserMembership, Collection } from "@server/models"; import { buildUser, buildTeam, @@ -26,7 +26,7 @@ describe("admin", () => { expect(abilities.readDocument).toEqual(false); expect(abilities.createDocument).toEqual(false); expect(abilities.share).toEqual(false); - expect(abilities.read).toEqual(true); + expect(abilities.read).toEqual(false); expect(abilities.update).toEqual(true); }); @@ -59,7 +59,7 @@ describe("member", () => { teamId: team.id, permission: CollectionPermission.ReadWrite, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -104,7 +104,7 @@ describe("member", () => { teamId: team.id, permission: CollectionPermission.ReadWrite, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -147,7 +147,7 @@ describe("member", () => { teamId: team.id, permission: CollectionPermission.Read, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -192,7 +192,7 @@ describe("member", () => { teamId: team.id, permission: null, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -242,7 +242,7 @@ describe("viewer", () => { teamId: team.id, permission: CollectionPermission.ReadWrite, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -271,7 +271,7 @@ describe("viewer", () => { teamId: team.id, permission: CollectionPermission.Read, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -317,7 +317,7 @@ describe("viewer", () => { teamId: team.id, permission: null, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, diff --git a/server/policies/collection.ts b/server/policies/collection.ts index 2f9bde971..fdb6e08a0 100644 --- a/server/policies/collection.ts +++ b/server/policies/collection.ts @@ -1,6 +1,6 @@ import invariant from "invariant"; import some from "lodash/some"; -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, DocumentPermission } from "@shared/types"; import { Collection, User, Team } from "@server/models"; import { AdminRequiredError } from "../errors"; import { allow } from "./cancan"; @@ -45,10 +45,6 @@ allow(User, "read", Collection, (user, collection) => { return false; } - if (user.isAdmin) { - return true; - } - if (collection.isPrivate) { return includesMembership(collection, Object.values(CollectionPermission)); } @@ -144,11 +140,11 @@ allow(User, ["update", "delete"], Collection, (user, collection) => { function includesMembership( collection: Collection, - permissions: CollectionPermission[] + permissions: (CollectionPermission | DocumentPermission)[] ) { invariant( collection.memberships, - "memberships should be preloaded, did you forget withMembership scope?" + "collection memberships should be preloaded, did you forget withMembership scope?" ); return some( [...collection.memberships, ...collection.collectionGroupMemberships], diff --git a/server/policies/document.test.ts b/server/policies/document.test.ts index a0f0bc7f9..bc7e50784 100644 --- a/server/policies/document.test.ts +++ b/server/policies/document.test.ts @@ -1,4 +1,5 @@ import { CollectionPermission } from "@shared/types"; +import { Document } from "@server/models"; import { buildUser, buildTeam, @@ -16,10 +17,12 @@ describe("read_write collection", () => { teamId: team.id, permission: CollectionPermission.ReadWrite, }); - const document = await buildDocument({ + const doc = await buildDocument({ teamId: team.id, collectionId: collection.id, }); + // reload to get membership + const document = await Document.findByPk(doc.id, { userId: user.id }); const abilities = serialize(user, document); expect(abilities.read).toEqual(true); expect(abilities.download).toEqual(true); @@ -42,10 +45,12 @@ describe("read_write collection", () => { teamId: team.id, permission: CollectionPermission.ReadWrite, }); - const document = await buildDocument({ + const doc = await buildDocument({ teamId: team.id, collectionId: collection.id, }); + // reload to get membership + const document = await Document.findByPk(doc.id, { userId: user.id }); const abilities = serialize(user, document); expect(abilities.read).toEqual(true); expect(abilities.download).toEqual(true); @@ -69,10 +74,12 @@ describe("read collection", () => { teamId: team.id, permission: CollectionPermission.Read, }); - const document = await buildDocument({ + const doc = await buildDocument({ teamId: team.id, collectionId: collection.id, }); + // reload to get membership + const document = await Document.findByPk(doc.id, { userId: user.id }); const abilities = serialize(user, document); expect(abilities.read).toEqual(true); expect(abilities.download).toEqual(true); @@ -116,33 +123,46 @@ describe("private collection", () => { }); describe("no collection", () => { - it("should grant same permissions as that on a draft document except the share permission", async () => { + it("should allow no permissions for team member", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); const document = await buildDraftDocument({ teamId: team.id, - collectionId: null, }); const abilities = serialize(user, document); - expect(abilities.archive).toEqual(false); + expect(abilities.read).toEqual(false); + expect(abilities.download).toEqual(false); + expect(abilities.update).toEqual(false); expect(abilities.createChildDocument).toEqual(false); - expect(abilities.delete).toEqual(true); - expect(abilities.download).toEqual(true); - expect(abilities.move).toEqual(true); - expect(abilities.permanentDelete).toEqual(false); - expect(abilities.pin).toEqual(false); - expect(abilities.pinToHome).toEqual(false); - expect(abilities.read).toEqual(true); - expect(abilities.restore).toEqual(false); - expect(abilities.share).toEqual(true); - expect(abilities.star).toEqual(true); + expect(abilities.archive).toEqual(false); + expect(abilities.delete).toEqual(false); + expect(abilities.share).toEqual(false); + expect(abilities.move).toEqual(false); expect(abilities.subscribe).toEqual(false); - expect(abilities.unarchive).toEqual(false); - expect(abilities.unpin).toEqual(false); - expect(abilities.unpublish).toEqual(false); - expect(abilities.unstar).toEqual(true); expect(abilities.unsubscribe).toEqual(false); + expect(abilities.comment).toEqual(false); + }); + + it("should allow edit permissions for creator", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const doc = await buildDraftDocument({ + teamId: team.id, + userId: user.id, + }); + // reload to get membership + const document = await Document.findByPk(doc.id, { userId: user.id }); + const abilities = serialize(user, document); + expect(abilities.read).toEqual(true); + expect(abilities.download).toEqual(true); expect(abilities.update).toEqual(true); + expect(abilities.createChildDocument).toEqual(false); + expect(abilities.archive).toEqual(false); + expect(abilities.delete).toEqual(true); + expect(abilities.share).toEqual(true); + expect(abilities.move).toEqual(true); + expect(abilities.subscribe).toEqual(false); + expect(abilities.unsubscribe).toEqual(false); expect(abilities.comment).toEqual(true); }); }); diff --git a/server/policies/document.ts b/server/policies/document.ts index 02d9a1c06..85f01052a 100644 --- a/server/policies/document.ts +++ b/server/policies/document.ts @@ -1,7 +1,12 @@ import invariant from "invariant"; -import { TeamPreference } from "@shared/types"; +import some from "lodash/some"; +import { + CollectionPermission, + DocumentPermission, + TeamPreference, +} from "@shared/types"; import { Document, Revision, User, Team } from "@server/models"; -import { allow, _cannot as cannot } from "./cancan"; +import { allow, _cannot as cannot, _can as can } from "./cancan"; allow(User, "createDocument", Team, (user, team) => { if (!team || user.isViewer || user.teamId !== team.id) { @@ -15,6 +20,15 @@ allow(User, "read", Document, (user, document) => { return false; } + if ( + includesMembership(document, [ + DocumentPermission.Read, + DocumentPermission.ReadWrite, + ]) + ) { + return true; + } + // existence of collection option is not required here to account for share tokens if ( document.collection && @@ -23,6 +37,10 @@ allow(User, "read", Document, (user, document) => { return false; } + if (document.isDraft) { + return user.id === document.createdById; + } + return user.teamId === document.teamId; }); @@ -31,6 +49,22 @@ allow(User, "download", Document, (user, document) => { return false; } + if ( + user.isViewer && + !user.team.getPreference(TeamPreference.ViewersCanExport) + ) { + return false; + } + + if ( + includesMembership(document, [ + DocumentPermission.Read, + DocumentPermission.ReadWrite, + ]) + ) { + return true; + } + // existence of collection option is not required here to account for share tokens if ( document.collection && @@ -39,40 +73,52 @@ allow(User, "download", Document, (user, document) => { return false; } - if ( - user.isViewer && - !user.team.getPreference(TeamPreference.ViewersCanExport) - ) { - return false; + if (document.isDraft) { + return user.id === document.createdById; } return user.teamId === document.teamId; }); -allow(User, ["star", "comment"], Document, (user, document) => { +allow(User, "comment", Document, (user, document) => { if (!document || !document.isActive || document.template) { return false; } + if ( + includesMembership(document, [ + DocumentPermission.Read, + DocumentPermission.ReadWrite, + ]) + ) { + return true; + } + if (document.collectionId) { invariant( document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "readDocument", document.collection)) { - return false; + if (can(user, "readDocument", document.collection)) { + return true; } } - return user.teamId === document.teamId; + return user.id === document.createdById; }); -allow(User, "unstar", Document, (user, document) => { - if (!document) { +allow(User, ["star", "unstar"], Document, (user, document) => { + if (!document || !document.isActive || document.template) { return false; } - if (document.template) { - return false; + + if ( + includesMembership(document, [ + DocumentPermission.Read, + DocumentPermission.ReadWrite, + ]) + ) { + return true; } if (document.collectionId) { @@ -89,13 +135,12 @@ allow(User, "unstar", Document, (user, document) => { }); allow(User, "share", Document, (user, document) => { - if (!document) { - return false; - } - if (document.archivedAt) { - return false; - } - if (document.deletedAt) { + if ( + !document || + document.archivedAt || + document.deletedAt || + document.template + ) { return false; } @@ -104,12 +149,15 @@ allow(User, "share", Document, (user, document) => { document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "share", document.collection)) { return false; } } + if (document.isDraft) { + return user.id === document.createdById; + } + return user.teamId === document.teamId; }); @@ -118,20 +166,63 @@ allow(User, "update", Document, (user, document) => { return false; } + if (includesMembership(document, [DocumentPermission.ReadWrite])) { + return true; + } + if (document.collectionId) { invariant( document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "updateDocument", document.collection)) { return false; } } + if (document.isDraft) { + return user.id === document.createdById; + } + return user.teamId === document.teamId; }); +allow(User, "publish", Document, (user, document) => { + if (!document || !document.isActive || !document.isDraft) { + return false; + } + + if (document.collectionId) { + invariant( + document.collection, + "collection is missing, did you forget to include in the query scope?" + ); + if (can(user, "updateDocument", document.collection)) { + return true; + } + } + + return user.id === document.createdById; +}); + +allow(User, ["manageUsers", "duplicate"], Document, (user, document) => { + if (!document || !document.isActive) { + return false; + } + + if (document.collectionId) { + invariant( + document.collection, + "collection is missing, did you forget to include in the query scope?" + ); + if (can(user, "updateDocument", document.collection)) { + return true; + } + } + + return user.id === document.createdById; +}); + allow(User, "updateInsights", Document, (user, document) => { if (!document || !document.isActive) { return false; @@ -142,48 +233,49 @@ allow(User, "updateInsights", Document, (user, document) => { document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "update", document.collection)) { - return false; + if (can(user, "update", document.collection)) { + return true; } } - return user.teamId === document.teamId; + + return user.id === document.createdById; }); allow(User, "createChildDocument", Document, (user, document) => { - if (!document || !document.isActive || document.isDraft) { - return false; - } - if (document.template) { + if ( + !document || + !document.isActive || + document.isDraft || + document.template + ) { return false; } + invariant( document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "updateDocument", document.collection)) { - return false; + if (can(user, "updateDocument", document.collection)) { + return true; } - return user.teamId === document.teamId; + return user.id === document.createdById; }); allow(User, "move", Document, (user, document) => { if (!document || !document.isActive) { return false; } - if ( - document.collection && - cannot(user, "updateDocument", document.collection) - ) { - return false; + if (document.collection && can(user, "updateDocument", document.collection)) { + return true; } - return user.teamId === document.teamId; + return user.id === document.createdById; }); allow(User, "pin", Document, (user, document) => { if ( !document || - document.isDraft || !document.isActive || + document.isDraft || document.template ) { return false; @@ -192,10 +284,10 @@ allow(User, "pin", Document, (user, document) => { document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "update", document.collection)) { - return false; + if (can(user, "update", document.collection)) { + return true; } - return user.teamId === document.teamId; + return user.id === document.createdById; }); allow(User, "unpin", Document, (user, document) => { @@ -206,10 +298,10 @@ allow(User, "unpin", Document, (user, document) => { document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "update", document.collection)) { - return false; + if (can(user, "update", document.collection)) { + return true; } - return user.teamId === document.teamId; + return user.id === document.createdById; }); allow(User, ["subscribe", "unsubscribe"], Document, (user, document) => { @@ -221,15 +313,25 @@ allow(User, ["subscribe", "unsubscribe"], Document, (user, document) => { ) { return false; } + + if ( + includesMembership(document, [ + DocumentPermission.Read, + DocumentPermission.ReadWrite, + ]) + ) { + return true; + } + invariant( document.collection, "collection is missing, did you forget to include in the query scope?" ); - if (cannot(user, "readDocument", document.collection)) { - return false; + if (can(user, "readDocument", document.collection)) { + return true; } - return user.teamId === document.teamId; + return user.id === document.createdById; }); allow(User, "pinToHome", Document, (user, document) => { @@ -259,12 +361,8 @@ allow(User, "delete", Document, (user, document) => { } // unpublished drafts can always be deleted by their owner - if ( - !document.deletedAt && - document.isDraft && - user.id === document.createdById - ) { - return true; + if (document.isDraft) { + return user.id === document.createdById; } return user.teamId === document.teamId; @@ -303,6 +401,11 @@ allow(User, "restore", Document, (user, document) => { return false; } + // unpublished drafts can always be restored by their owner + if (document.isDraft && user.id === document.createdById) { + return true; + } + return user.teamId === document.teamId; }); @@ -329,6 +432,7 @@ allow(User, "unarchive", Document, (user, document) => { if (!document || !document.archivedAt || document.deletedAt) { return false; } + invariant( document.collection, "collection is missing, did you forget to include in the query scope?" @@ -337,6 +441,10 @@ allow(User, "unarchive", Document, (user, document) => { return false; } + if (document.isDraft) { + return user.id === document.createdById; + } + return user.teamId === document.teamId; }); @@ -360,3 +468,14 @@ allow(User, "unpublish", Document, (user, document) => { } return user.teamId === document.teamId; }); + +function includesMembership( + document: Document, + permissions: (DocumentPermission | CollectionPermission)[] +) { + invariant( + document.memberships, + "document memberships should be preloaded, did you forget withMembership scope?" + ); + return some(document.memberships, (m) => permissions.includes(m.permission)); +} diff --git a/server/policies/index.ts b/server/policies/index.ts index 4437fb4ff..8ec1c5104 100644 --- a/server/policies/index.ts +++ b/server/policies/index.ts @@ -8,6 +8,7 @@ import { Document, Group, Notification, + UserMembership, } from "@server/models"; import { _abilities, _can, _cannot, _authorize } from "./cancan"; import "./apiKey"; @@ -28,6 +29,7 @@ import "./team"; import "./group"; import "./webhookSubscription"; import "./notification"; +import "./userMembership"; type Policy = Record; @@ -58,6 +60,7 @@ export function serialize( | User | Group | Notification + | UserMembership | null ): Policy { const output = {}; diff --git a/server/policies/userMembership.ts b/server/policies/userMembership.ts new file mode 100644 index 000000000..9eefdce2f --- /dev/null +++ b/server/policies/userMembership.ts @@ -0,0 +1,9 @@ +import { User, UserMembership } from "@server/models"; +import { allow } from "./cancan"; + +allow( + User, + ["update", "delete"], + UserMembership, + (user, membership) => user.id === membership?.userId || user.isAdmin +); diff --git a/server/presenters/document.ts b/server/presenters/document.ts index 50932466c..6f7af45d8 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -41,6 +41,7 @@ async function presentDocument( collectionId: undefined, parentDocumentId: undefined, lastViewedAt: undefined, + isCollectionDeleted: await document.isCollectionDeleted(), }; if (!!document.views && document.views.length > 0) { diff --git a/server/presenters/membership.ts b/server/presenters/membership.ts index fcc1c0399..f74931934 100644 --- a/server/presenters/membership.ts +++ b/server/presenters/membership.ts @@ -1,20 +1,28 @@ -import { CollectionPermission } from "@shared/types"; -import { UserPermission } from "@server/models"; +import { CollectionPermission, DocumentPermission } from "@shared/types"; +import { UserMembership } from "@server/models"; type Membership = { id: string; userId: string; collectionId?: string | null; - permission: CollectionPermission; + documentId?: string | null; + sourceId?: string | null; + createdById: string; + permission: CollectionPermission | DocumentPermission; + index: string | null; }; export default function presentMembership( - membership: UserPermission + membership: UserMembership ): Membership { return { id: membership.id, userId: membership.userId, + documentId: membership.documentId, collectionId: membership.collectionId, permission: membership.permission, + createdById: membership.createdById, + sourceId: membership.sourceId, + index: membership.index, }; } diff --git a/server/queues/processors/UserDeletedProcessor.ts b/server/queues/processors/UserDeletedProcessor.ts index b767307ea..d2cb39492 100644 --- a/server/queues/processors/UserDeletedProcessor.ts +++ b/server/queues/processors/UserDeletedProcessor.ts @@ -4,7 +4,7 @@ import { Star, Subscription, UserAuthentication, - UserPermission, + UserMembership, } from "@server/models"; import { sequelize } from "@server/storage/database"; import { Event as TEvent, UserEvent } from "@server/types"; @@ -27,7 +27,7 @@ export default class UsersDeletedProcessor extends BaseProcessor { }, transaction, }); - await UserPermission.destroy({ + await UserMembership.destroy({ where: { userId: event.userId, }, diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index 0c0f7d820..b22826e06 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -14,6 +14,7 @@ import { Team, Subscription, Notification, + UserMembership, } from "@server/models"; import { presentComment, @@ -25,12 +26,13 @@ import { presentStar, presentSubscription, presentTeam, + presentMembership, } from "@server/presenters"; import presentNotification from "@server/presenters/notification"; import { Event } from "../../types"; export default class WebsocketsProcessor { - async perform(event: Event, socketio: Server) { + public async perform(event: Event, socketio: Server) { switch (event.name) { case "documents.publish": case "documents.unpublish": @@ -43,10 +45,8 @@ export default class WebsocketsProcessor { return; } - const channel = document.publishedAt - ? `collection-${document.collectionId}` - : `user-${event.actorId}`; - return socketio.to(channel).emit("entities", { + const channels = await this.getDocumentEventChannels(event, document); + return socketio.to(channels).emit("entities", { event: event.name, documentIds: [ { @@ -79,12 +79,9 @@ export default class WebsocketsProcessor { if (!document) { return; } - const channel = document.publishedAt - ? `collection-${document.collectionId}` - : `user-${event.actorId}`; - const data = await presentDocument(document); - return socketio.to(channel).emit(event.name, data); + const channels = await this.getDocumentEventChannels(event, document); + return socketio.to(channels).emit(event.name, data); } case "documents.create": { @@ -92,7 +89,9 @@ export default class WebsocketsProcessor { if (!document) { return; } - return socketio.to(`user-${event.actorId}`).emit("entities", { + + const channels = await this.getDocumentEventChannels(event, document); + return socketio.to(channels).emit("entities", { event: event.name, documentIds: [ { @@ -139,6 +138,35 @@ export default class WebsocketsProcessor { return; } + case "documents.add_user": { + const [document, membership] = await Promise.all([ + Document.findByPk(event.documentId), + UserMembership.findByPk(event.modelId), + ]); + if (!document || !membership) { + return; + } + + const channels = await this.getDocumentEventChannels(event, document); + socketio.to(channels).emit(event.name, presentMembership(membership)); + return; + } + + case "documents.remove_user": { + const document = await Document.findByPk(event.documentId); + if (!document) { + return; + } + + const channels = await this.getDocumentEventChannels(event, document); + socketio.to([...channels, `user-${event.userId}`]).emit(event.name, { + id: event.modelId, + userId: event.userId, + documentId: event.documentId, + }); + return; + } + case "collections.create": { const collection = await Collection.findByPk(event.collectionId, { paranoid: false, @@ -372,24 +400,47 @@ export default class WebsocketsProcessor { case "comments.create": case "comments.update": { - const comment = await Comment.scope([ - "defaultScope", - "withDocument", - ]).findByPk(event.modelId); + const comment = await Comment.findByPk(event.modelId, { + include: [ + { + model: Document.scope(["withoutState", "withDrafts"]), + as: "document", + required: true, + }, + ], + }); if (!comment) { return; } - return socketio - .to(`collection-${comment.document.collectionId}`) - .emit(event.name, presentComment(comment)); + + const channels = await this.getDocumentEventChannels( + event, + comment.document + ); + return socketio.to(channels).emit(event.name, presentComment(comment)); } case "comments.delete": { - return socketio - .to(`collection-${event.collectionId}`) - .emit(event.name, { - modelId: event.modelId, - }); + const comment = await Comment.findByPk(event.modelId, { + include: [ + { + model: Document.scope(["withoutState", "withDrafts"]), + as: "document", + required: true, + }, + ], + }); + if (!comment) { + return; + } + + const channels = await this.getDocumentEventChannels( + event, + comment.document + ); + return socketio.to(channels).emit(event.name, { + modelId: event.modelId, + }); } case "notifications.create": @@ -622,8 +673,41 @@ export default class WebsocketsProcessor { .emit(event.name, { id: event.userId }); } + case "userMemberships.update": { + return socketio + .to(`user-${event.userId}`) + .emit(event.name, { id: event.modelId, ...event.data }); + } + default: return; } } + + private async getDocumentEventChannels( + event: Event, + document: Document + ): Promise { + const channels = []; + + if (event.actorId) { + channels.push(`user-${event.actorId}`); + } + + if (document.publishedAt) { + channels.push(`collection-${document.collectionId}`); + } + + const memberships = await UserMembership.findAll({ + where: { + documentId: document.id, + }, + }); + + for (const membership of memberships) { + channels.push(`user-${membership.userId}`); + } + + return channels; + } } diff --git a/server/routes/api/attachments/attachments.test.ts b/server/routes/api/attachments/attachments.test.ts index 674ec2d47..022ece0b3 100644 --- a/server/routes/api/attachments/attachments.test.ts +++ b/server/routes/api/attachments/attachments.test.ts @@ -1,5 +1,5 @@ import { AttachmentPreset, CollectionPermission } from "@shared/types"; -import { UserPermission } from "@server/models"; +import { UserMembership } from "@server/models"; import Attachment from "@server/models/Attachment"; import { buildUser, @@ -123,7 +123,7 @@ describe("#attachments.create", () => { collectionId: collection.id, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, diff --git a/server/routes/api/collections/collections.test.ts b/server/routes/api/collections/collections.test.ts index 8e6304552..fb8ec1ed1 100644 --- a/server/routes/api/collections/collections.test.ts +++ b/server/routes/api/collections/collections.test.ts @@ -1,6 +1,6 @@ import { CollectionPermission } from "@shared/types"; import { colorPalette } from "@shared/utils/collections"; -import { Document, UserPermission, GroupPermission } from "@server/models"; +import { Document, UserMembership, GroupPermission } from "@server/models"; import { buildUser, buildAdmin, @@ -310,7 +310,7 @@ describe("#collections.export", () => { const collection = await buildCollection({ teamId: team.id }); collection.permission = null; await collection.save(); - await UserPermission.create({ + await UserMembership.create({ createdById: admin.id, collectionId: collection.id, userId: admin.id, @@ -772,7 +772,7 @@ describe("#collections.group_memberships", () => { permission: null, teamId: user.teamId, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -816,7 +816,7 @@ describe("#collections.group_memberships", () => { permission: null, teamId: user.teamId, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -859,7 +859,7 @@ describe("#collections.group_memberships", () => { permission: null, teamId: user.teamId, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -952,7 +952,7 @@ describe("#collections.memberships", () => { const user2 = await buildUser({ name: "Won't find", }); - await UserPermission.create({ + await UserMembership.create({ createdById: user2.id, collectionId: collection.id, userId: user2.id, @@ -979,13 +979,13 @@ describe("#collections.memberships", () => { teamId: team.id, }); const user2 = await buildUser(); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, permission: CollectionPermission.ReadWrite, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user2.id, collectionId: collection.id, userId: user2.id, @@ -1052,7 +1052,7 @@ describe("#collections.info", () => { }); collection.permission = null; await collection.save(); - await UserPermission.destroy({ + await UserMembership.destroy({ where: { collectionId: collection.id, userId: user.id, @@ -1076,7 +1076,7 @@ describe("#collections.info", () => { }); collection.permission = null; await collection.save(); - await UserPermission.create({ + await UserMembership.create({ collectionId: collection.id, userId: user.id, createdById: user.id, @@ -1368,7 +1368,7 @@ describe("#collections.update", () => { const collection = await buildCollection({ teamId: team.id }); collection.permission = null; await collection.save(); - await UserPermission.create({ + await UserMembership.create({ collectionId: collection.id, userId: admin.id, createdById: admin.id, @@ -1397,7 +1397,7 @@ describe("#collections.update", () => { const collection = await buildCollection({ teamId: team.id }); collection.permission = null; await collection.save(); - await UserPermission.create({ + await UserMembership.create({ collectionId: collection.id, userId: admin.id, createdById: admin.id, @@ -1458,7 +1458,7 @@ describe("#collections.update", () => { }); collection.permission = null; await collection.save(); - await UserPermission.update( + await UserMembership.update( { createdById: user.id, permission: CollectionPermission.Read, diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index 7781a9bbf..59ae19da2 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -16,7 +16,7 @@ import { transaction } from "@server/middlewares/transaction"; import validate from "@server/middlewares/validate"; import { Collection, - UserPermission, + UserMembership, GroupPermission, Team, Event, @@ -397,7 +397,7 @@ router.post( const user = await User.findByPk(userId); authorize(actor, "read", user); - let membership = await UserPermission.findOne({ + let membership = await UserMembership.findOne({ where: { collectionId: id, userId, @@ -407,7 +407,7 @@ router.post( }); if (!membership) { - membership = await UserPermission.create( + membership = await UserMembership.create( { collectionId: id, userId, @@ -514,7 +514,7 @@ router.post( }).findByPk(id); authorize(user, "read", collection); - let where: WhereOptions = { + let where: WhereOptions = { collectionId: id, }; let userWhere; @@ -544,8 +544,8 @@ router.post( }; const [total, memberships] = await Promise.all([ - UserPermission.count(options), - UserPermission.findAll({ + UserMembership.count(options), + UserMembership.findAll({ ...options, order: [["createdAt", "DESC"]], offset: ctx.state.pagination.offset, @@ -656,7 +656,7 @@ router.post( permission !== CollectionPermission.ReadWrite && collection.permission === CollectionPermission.ReadWrite ) { - await UserPermission.findOrCreate({ + await UserMembership.findOrCreate({ where: { collectionId: collection.id, userId: user.id, diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index d986ecc1f..988766e93 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -1,12 +1,12 @@ import { faker } from "@faker-js/faker"; import { addMinutes, subDays } from "date-fns"; -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, DocumentPermission } from "@shared/types"; import { Document, View, Revision, Backlink, - UserPermission, + UserMembership, SearchQuery, Event, User, @@ -28,6 +28,10 @@ import { getTestServer } from "@server/test/support"; const server = getTestServer(); +beforeEach(async () => { + await buildDocument(); +}); + describe("#documents.info", () => { it("should fail if both id and shareId are absent", async () => { const res = await server.post("/api/documents.info", { @@ -907,7 +911,7 @@ describe("#documents.list", () => { collectionId: collection.id, }); - await UserPermission.update( + await UserMembership.update( { userId: user.id, permission: CollectionPermission.Read, @@ -1068,6 +1072,85 @@ describe("#documents.drafts", () => { }); describe("#documents.search_titles", () => { + it("should include individually shared drafts with a user in search results", async () => { + const user = await buildUser(); + // create a private collection + const collection = await buildCollection({ + createdById: user.id, + teamId: user.teamId, + permission: null, + }); + // create a draft in collection + const document = await buildDocument({ + collectionId: collection.id, + teamId: user.teamId, + createdById: user.id, + title: "Some title", + }); + document.publishedAt = null; + await document.save(); + const member = await buildUser({ + teamId: user.teamId, + }); + // add member to the document + await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: member.getJwtToken(), + query: "title", + includeDrafts: true, + }, + }); + const body = await res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toEqual(document.id); + expect(res.status).toEqual(200); + }); + + it("should include individually shared docs with a user in search results", async () => { + const user = await buildUser(); + // create a private collection + const collection = await buildCollection({ + createdById: user.id, + teamId: user.teamId, + permission: null, + }); + // create document in that private collection + const document = await buildDocument({ + collectionId: collection.id, + teamId: user.teamId, + createdById: user.id, + title: "Some title", + }); + const member = await buildUser({ + teamId: user.teamId, + }); + // add member to the document + await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: member.getJwtToken(), + query: "title", + }, + }); + const body = await res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toEqual(document.id); + expect(res.status).toEqual(200); + }); + it("should fail without query", async () => { const user = await buildUser(); const res = await server.post("/api/documents.search_titles", { @@ -1615,7 +1698,7 @@ describe("#documents.search", () => { permission: null, }); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -1745,6 +1828,85 @@ describe("#documents.search", () => { expect(searchQuery[0].results).toBe(0); expect(searchQuery[0].source).toBe("app"); }); + + it("should include individually shared docs with a user in search results", async () => { + const user = await buildUser(); + // create a private collection + const collection = await buildCollection({ + createdById: user.id, + teamId: user.teamId, + permission: null, + }); + // create document in that private collection + const document = await buildDocument({ + collectionId: collection.id, + teamId: user.teamId, + createdById: user.id, + title: "Some title", + }); + const member = await buildUser({ + teamId: user.teamId, + }); + // add member to the document + await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + const res = await server.post("/api/documents.search", { + body: { + token: member.getJwtToken(), + query: "title", + }, + }); + const body = await res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].document.id).toEqual(document.id); + expect(res.status).toEqual(200); + }); + + it("should include individually shared drafts with a user in search results", async () => { + const user = await buildUser(); + // create a private collection + const collection = await buildCollection({ + createdById: user.id, + teamId: user.teamId, + permission: null, + }); + // create a draft in collection + const document = await buildDocument({ + collectionId: collection.id, + teamId: user.teamId, + createdById: user.id, + title: "Some title", + }); + document.publishedAt = null; + await document.save(); + const member = await buildUser({ + teamId: user.teamId, + }); + // add member to the document + await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + const res = await server.post("/api/documents.search", { + body: { + token: member.getJwtToken(), + query: "title", + includeDrafts: true, + }, + }); + const body = await res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].document.id).toEqual(document.id); + expect(res.status).toEqual(200); + }); }); describe("#documents.templatize", () => { @@ -1990,7 +2152,7 @@ describe("#documents.viewed", () => { documentId: document.id, userId: user.id, }); - await UserPermission.destroy({ + await UserMembership.destroy({ where: { userId: user.id, collectionId: collection.id, @@ -2889,6 +3051,7 @@ describe("#documents.update", () => { const user = await buildUser({ teamId: team.id }); const document = await buildDraftDocument({ teamId: team.id, + userId: user.id, collectionId: null, }); @@ -2920,6 +3083,7 @@ describe("#documents.update", () => { title: "title", text: "text", teamId: team.id, + userId: user.id, collectionId: null, }); const res = await server.post("/api/documents.update", { @@ -3012,6 +3176,7 @@ describe("#documents.update", () => { }); const template = await buildDocument({ teamId: user.teamId, + userId: user.id, collectionId: collection.id, template: true, publishedAt: null, @@ -3045,7 +3210,7 @@ describe("#documents.update", () => { userId: user.id, teamId: user.teamId, }); - await UserPermission.update( + await UserMembership.update( { userId: user.id, permission: CollectionPermission.ReadWrite, @@ -3152,7 +3317,7 @@ describe("#documents.update", () => { teamId: team.id, }); - await UserPermission.update( + await UserMembership.update( { createdById: user.id, permission: CollectionPermission.ReadWrite, @@ -3190,7 +3355,7 @@ describe("#documents.update", () => { collectionId: collection.id, teamId: team.id, }); - await UserPermission.update( + await UserMembership.update( { createdById: user.id, permission: CollectionPermission.Read, @@ -3226,7 +3391,7 @@ describe("#documents.update", () => { }); collection.permission = CollectionPermission.Read; await collection.save(); - await UserPermission.destroy({ + await UserMembership.destroy({ where: { userId: user.id, collectionId: collection.id, @@ -3455,6 +3620,7 @@ describe("#documents.delete", () => { const user = await buildUser(); const document = await buildDraftDocument({ teamId: user.teamId, + userId: user.id, deletedAt: null, }); const res = await server.post("/api/documents.delete", { @@ -3811,19 +3977,19 @@ describe("#documents.users", () => { // add people and groups to collection await Promise.all([ - UserPermission.create({ + UserMembership.create({ collectionId: collection.id, userId: alan.id, permission: CollectionPermission.Read, createdById: user.id, }), - UserPermission.create({ + UserMembership.create({ collectionId: collection.id, userId: bret.id, permission: CollectionPermission.Read, createdById: user.id, }), - UserPermission.create({ + UserMembership.create({ collectionId: collection.id, userId: ken.id, permission: CollectionPermission.Read, @@ -3901,19 +4067,19 @@ describe("#documents.users", () => { // add people to collection await Promise.all([ - UserPermission.create({ + UserMembership.create({ collectionId: collection.id, userId: alan.id, permission: CollectionPermission.Read, createdById: user.id, }), - UserPermission.create({ + UserMembership.create({ collectionId: collection.id, userId: bret.id, permission: CollectionPermission.Read, createdById: user.id, }), - UserPermission.create({ + UserMembership.create({ collectionId: collection.id, userId: ken.id, permission: CollectionPermission.Read, @@ -3942,3 +4108,256 @@ describe("#documents.users", () => { expect(memberIds).toContain(ken.id); }); }); + +describe("#documents.add_user", () => { + it("should require id", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + + it("should require authentication", async () => { + const document = await buildDocument(); + const res = await server.post("/api/documents.add_user", { + body: { + id: document.id, + }, + }); + expect(res.status).toEqual(401); + }); + + it("should fail with status 400 bad request if user attempts to invite themself", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + permission: null, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: user.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("You cannot invite yourself"); + }); + + it("should succeed with status 200 ok", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + permission: null, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const member = await buildUser({ teamId: user.teamId }); + + const res = await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).not.toBeFalsy(); + expect(body.data.users).not.toBeFalsy(); + expect(body.data.users).toHaveLength(1); + expect(body.data.users[0].id).toEqual(member.id); + expect(body.data.memberships).not.toBeFalsy(); + expect(body.data.memberships[0].userId).toEqual(member.id); + expect(body.data.memberships[0].documentId).toEqual(document.id); + expect(body.data.memberships[0].permission).toEqual( + DocumentPermission.ReadWrite + ); + }); +}); + +describe("#documents.remove_user", () => { + it("should require id", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.remove_user", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + + it("should require authentication", async () => { + const document = await buildDocument(); + const res = await server.post("/api/documents.remove_user", { + body: { + id: document.id, + }, + }); + expect(res.status).toEqual(401); + }); + + it("should require authorization", async () => { + const document = await buildDocument(); + const user = await buildUser(); + const anotherUser = await buildUser({ + teamId: user.teamId, + }); + const res = await server.post("/api/documents.remove_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: anotherUser.id, + }, + }); + expect(res.status).toEqual(403); + }); + + it("should remove user from document", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + permission: null, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const member = await buildUser({ + teamId: user.teamId, + }); + await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + let users = await document.$get("users"); + expect(users.length).toEqual(1); + const res = await server.post("/api/documents.remove_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + users = await document.$get("users"); + expect(res.status).toEqual(200); + expect(users.length).toEqual(0); + }); +}); + +describe("#documents.memberships", () => { + let actor: User, document: Document; + beforeEach(async () => { + actor = await buildUser(); + const collection = await buildCollection({ + teamId: actor.teamId, + createdById: actor.id, + permission: null, + }); + document = await buildDocument({ + collectionId: collection.id, + createdById: actor.id, + teamId: actor.teamId, + }); + }); + + it("should return members in document", async () => { + const members = await Promise.all([ + buildUser({ teamId: actor.teamId }), + buildUser({ teamId: actor.teamId }), + ]); + await Promise.all([ + server.post("/api/documents.add_user", { + body: { + token: actor.getJwtToken(), + id: document.id, + userId: members[0].id, + }, + }), + server.post("/api/documents.add_user", { + body: { + token: actor.getJwtToken(), + id: document.id, + userId: members[1].id, + }, + }), + ]); + const res = await server.post("/api/documents.memberships", { + body: { + token: actor.getJwtToken(), + id: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.users.length).toEqual(2); + expect(body.data.users.map((u: User) => u.id).includes(members[0].id)).toBe( + true + ); + expect(body.data.users.map((u: User) => u.id).includes(members[1].id)).toBe( + true + ); + }); + + it("should allow filtering members in document by permission", async () => { + const members = await Promise.all([ + buildUser({ teamId: actor.teamId }), + buildUser({ teamId: actor.teamId }), + ]); + await Promise.all([ + server.post("/api/documents.add_user", { + body: { + token: actor.getJwtToken(), + id: document.id, + userId: members[0].id, + permission: DocumentPermission.ReadWrite, + }, + }), + server.post("/api/documents.add_user", { + body: { + token: actor.getJwtToken(), + id: document.id, + userId: members[1].id, + permission: DocumentPermission.Read, + }, + }), + ]); + const res = await server.post("/api/documents.memberships", { + body: { + token: actor.getJwtToken(), + id: document.id, + permission: DocumentPermission.Read, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.users.length).toEqual(1); + expect(body.data.users[0].id).toEqual(members[1].id); + }); +}); diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index d3964d8f1..07dc3a909 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1,11 +1,12 @@ import path from "path"; +import fractionalIndex from "fractional-index"; import fs from "fs-extra"; import invariant from "invariant"; import JSZip from "jszip"; import Router from "koa-router"; import escapeRegExp from "lodash/escapeRegExp"; import mime from "mime-types"; -import { Op, ScopeOptions, WhereOptions } from "sequelize"; +import { Op, ScopeOptions, Sequelize, WhereOptions } from "sequelize"; import { TeamPreference } from "@shared/types"; import { subtractDate } from "@shared/utils/date"; import slugify from "@shared/utils/slugify"; @@ -40,6 +41,7 @@ import { SearchQuery, User, View, + UserMembership, } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; import SearchHelper from "@server/models/helpers/SearchHelper"; @@ -48,6 +50,7 @@ import { presentCollection, presentDocument, presentPolicies, + presentMembership, presentPublicTeam, presentUser, } from "@server/presenters"; @@ -121,6 +124,17 @@ router.post( } if (parentDocumentId) { + const membership = await UserMembership.findOne({ + where: { + userId: user.id, + documentId: parentDocumentId, + }, + }); + + if (membership) { + delete where.collectionId; + } + where = { ...where, parentDocumentId }; } @@ -302,7 +316,10 @@ router.post( order: [[sort, direction]], include: [ { - model: Document, + model: Document.scope([ + "withDrafts", + { method: ["withMembership", userId] }, + ]), required: true, where: { collectionId: collectionIds, @@ -375,13 +392,7 @@ router.post( delete where.updatedAt; } - const collectionScope: Readonly = { - method: ["withCollectionPermissions", user.id], - }; - const documents = await Document.scope([ - "defaultScope", - collectionScope, - ]).findAll({ + const documents = await Document.defaultScopeWithUser(user.id).findAll({ where, order: [[sort, direction]], offset: ctx.state.pagination.offset, @@ -979,6 +990,7 @@ router.post( } if (publish) { + authorize(user, "publish", document); if (!document.collectionId) { assertPresent( collectionId, @@ -1415,11 +1427,8 @@ router.post( let parentDocument; if (parentDocumentId) { - parentDocument = await Document.findOne({ - where: { - id: parentDocumentId, - collectionId: collection?.id, - }, + parentDocument = await Document.findByPk(parentDocumentId, { + userId: user.id, }); authorize(user, "read", parentDocument, { collection, @@ -1462,4 +1471,220 @@ router.post( } ); +router.post( + "documents.add_user", + auth(), + validate(T.DocumentsAddUserSchema), + transaction(), + async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const actor = auth.user; + const { id, userId, permission } = ctx.input.body; + + if (userId === actor.id) { + throw ValidationError("You cannot invite yourself"); + } + + const [document, user] = await Promise.all([ + Document.findByPk(id, { + userId: actor.id, + rejectOnEmpty: true, + transaction, + }), + User.findByPk(userId, { + rejectOnEmpty: true, + transaction, + }), + ]); + + authorize(actor, "read", user); + authorize(actor, "manageUsers", document); + + const UserMemberships = await UserMembership.findAll({ + where: { + userId, + }, + attributes: ["id", "index", "updatedAt"], + limit: 1, + order: [ + // using LC_COLLATE:"C" because we need byte order to drive the sorting + // find only the first star so we can create an index before it + Sequelize.literal('"user_permission"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + transaction, + }); + + // create membership at the beginning of their "Shared with me" section + const index = fractionalIndex( + null, + UserMemberships.length ? UserMemberships[0].index : null + ); + + const [membership] = await UserMembership.findOrCreate({ + where: { + documentId: id, + userId, + }, + defaults: { + index, + permission: permission || user.defaultDocumentPermission, + createdById: actor.id, + }, + transaction, + lock: transaction.LOCK.UPDATE, + }); + + if (permission) { + membership.permission = permission; + + // disconnect from the source if the permission is manually updated + membership.sourceId = null; + + await membership.save({ transaction }); + } + + await Event.create( + { + name: "documents.add_user", + userId, + modelId: membership.id, + documentId: document.id, + teamId: document.teamId, + actorId: actor.id, + ip: ctx.request.ip, + }, + { + transaction, + } + ); + + ctx.body = { + data: { + users: [presentUser(user)], + memberships: [presentMembership(membership)], + }, + }; + } +); + +router.post( + "documents.remove_user", + auth(), + validate(T.DocumentsRemoveUserSchema), + transaction(), + async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const actor = auth.user; + const { id, userId } = ctx.input.body; + + const [document, user] = await Promise.all([ + Document.findByPk(id, { + userId: actor.id, + rejectOnEmpty: true, + transaction, + }), + User.findByPk(userId, { + rejectOnEmpty: true, + transaction, + }), + ]); + + if (actor.id !== userId) { + authorize(actor, "manageUsers", document); + authorize(actor, "read", user); + } + + const membership = await UserMembership.findOne({ + where: { + documentId: id, + userId, + }, + transaction, + lock: transaction.LOCK.UPDATE, + rejectOnEmpty: true, + }); + + await membership.destroy({ transaction }); + + await Event.create( + { + name: "documents.remove_user", + userId, + modelId: membership.id, + documentId: document.id, + teamId: document.teamId, + actorId: actor.id, + ip: ctx.request.ip, + }, + { transaction } + ); + + ctx.body = { + success: true, + }; + } +); + +router.post( + "documents.memberships", + auth(), + pagination(), + validate(T.DocumentsMembershipsSchema), + async (ctx: APIContext) => { + const { id, query, permission } = ctx.input.body; + const { user: actor } = ctx.state.auth; + + const document = await Document.findByPk(id, { userId: actor.id }); + authorize(actor, "update", document); + + let where: WhereOptions = { + documentId: id, + }; + let userWhere; + + if (query) { + userWhere = { + name: { + [Op.iLike]: `%${query}%`, + }, + }; + } + + if (permission) { + where = { ...where, permission }; + } + + const options = { + where, + include: [ + { + model: User, + as: "user", + where: userWhere, + required: true, + }, + ], + }; + + const [total, memberships] = await Promise.all([ + UserMembership.count(options), + UserMembership.findAll({ + ...options, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + ]); + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: { + memberships: memberships.map(presentMembership), + users: memberships.map((membership) => presentUser(membership.user)), + }, + }; + } +); + export default router; diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 647266142..c69e0f2a9 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -3,6 +3,7 @@ import formidable from "formidable"; import isEmpty from "lodash/isEmpty"; import isUUID from "validator/lib/isUUID"; import { z } from "zod"; +import { DocumentPermission } from "@shared/types"; import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; import { BaseSchema } from "@server/routes/api/schema"; @@ -353,3 +354,47 @@ export const DocumentsUsersSchema = BaseSchema.extend({ }); export type DocumentsUsersReq = z.infer; + +export const DocumentsAddUserSchema = BaseSchema.extend({ + body: z.object({ + /** Id of the document to which the user is supposed to be added */ + id: z.string().uuid(), + /** Id of the user who is to be added*/ + userId: z.string().uuid(), + /** Permission to be granted to the added user */ + permission: z.nativeEnum(DocumentPermission).optional(), + }), +}); + +export type DocumentsAddUserReq = z.infer; + +export const DocumentsRemoveUserSchema = BaseSchema.extend({ + body: z.object({ + /** Id of the document from which to remove the user */ + id: z.string().uuid(), + /** Id of the user who is to be removed */ + userId: z.string().uuid(), + }), +}); + +export type DocumentsRemoveUserReq = z.infer; + +export const DocumentsSharedWithUserSchema = BaseSchema.extend({ + body: DocumentsSortParamsSchema, +}); + +export type DocumentsSharedWithUserReq = z.infer< + typeof DocumentsSharedWithUserSchema +>; + +export const DocumentsMembershipsSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + query: z.string().optional(), + permission: z.nativeEnum(DocumentPermission).optional(), + }), +}); + +export type DocumentsMembershipsReq = z.infer< + typeof DocumentsMembershipsSchema +>; diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index eb48aa4dc..00ccb384f 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -34,6 +34,7 @@ import stars from "./stars"; import subscriptions from "./subscriptions"; import teams from "./teams"; import urls from "./urls"; +import userMemberships from "./userMemberships"; import users from "./users"; import views from "./views"; @@ -97,6 +98,7 @@ router.use("/", cron.routes()); router.use("/", groups.routes()); router.use("/", fileOperationsRoute.routes()); router.use("/", urls.routes()); +router.use("/", userMemberships.routes()); if (env.isDevelopment) { router.use("/", developer.routes()); diff --git a/server/routes/api/revisions/revisions.test.ts b/server/routes/api/revisions/revisions.test.ts index 850d82352..c34e8939a 100644 --- a/server/routes/api/revisions/revisions.test.ts +++ b/server/routes/api/revisions/revisions.test.ts @@ -1,4 +1,4 @@ -import { UserPermission, Revision } from "@server/models"; +import { UserMembership, Revision } from "@server/models"; import { buildCollection, buildDocument, @@ -175,7 +175,7 @@ describe("#revisions.list", () => { await Revision.createFromDocument(document); collection.permission = null; await collection.save(); - await UserPermission.destroy({ + await UserMembership.destroy({ where: { userId: user.id, collectionId: collection.id, diff --git a/server/routes/api/shares/shares.test.ts b/server/routes/api/shares/shares.test.ts index 121960097..4ccdcfbe4 100644 --- a/server/routes/api/shares/shares.test.ts +++ b/server/routes/api/shares/shares.test.ts @@ -1,5 +1,5 @@ import { CollectionPermission } from "@shared/types"; -import { UserPermission, Share } from "@server/models"; +import { UserMembership, Share } from "@server/models"; import { buildUser, buildDocument, @@ -263,7 +263,7 @@ describe("#shares.create", () => { }); collection.permission = null; await collection.save(); - await UserPermission.update( + await UserMembership.update( { userId: user.id, permission: CollectionPermission.Read, @@ -299,7 +299,7 @@ describe("#shares.create", () => { }); collection.permission = null; await collection.save(); - await UserPermission.update( + await UserMembership.update( { userId: user.id, permission: CollectionPermission.Read, diff --git a/server/routes/api/shares/shares.ts b/server/routes/api/shares/shares.ts index cc4e61af5..9e955bc44 100644 --- a/server/routes/api/shares/shares.ts +++ b/server/routes/api/shares/shares.ts @@ -243,12 +243,8 @@ router.post( if (published !== undefined) { share.published = published; - - // Reset nested document sharing when unpublishing a share link. So that - // If it's ever re-published this doesn't immediately share nested docs - // without forewarning the user - if (!published) { - share.includeChildDocuments = false; + if (published) { + share.includeChildDocuments = true; } } diff --git a/server/routes/api/userMemberships/index.ts b/server/routes/api/userMemberships/index.ts new file mode 100644 index 000000000..db3489243 --- /dev/null +++ b/server/routes/api/userMemberships/index.ts @@ -0,0 +1 @@ +export { default } from "./userMemberships"; diff --git a/server/routes/api/userMemberships/schema.ts b/server/routes/api/userMemberships/schema.ts new file mode 100644 index 000000000..f17ac0987 --- /dev/null +++ b/server/routes/api/userMemberships/schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { BaseSchema } from "@server/routes/api/schema"; +import { ValidateIndex } from "@server/validation"; + +export const UserMembershipsListSchema = BaseSchema; + +export type UserMembershipsListReq = z.infer; + +export const UserMembershipsUpdateSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + index: z.string().regex(ValidateIndex.regex, { + message: ValidateIndex.message, + }), + }), +}); + +export type UserMembershipsUpdateReq = z.infer< + typeof UserMembershipsUpdateSchema +>; diff --git a/server/routes/api/userMemberships/userMemberships.test.ts b/server/routes/api/userMemberships/userMemberships.test.ts new file mode 100644 index 000000000..152e38d0a --- /dev/null +++ b/server/routes/api/userMemberships/userMemberships.test.ts @@ -0,0 +1,110 @@ +import { + buildCollection, + buildDocument, + buildUser, +} from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("#userMemberships.list", () => { + it("should require authentication", async () => { + const res = await server.post("/api/userMemberships.list", { + body: {}, + }); + expect(res.status).toEqual(401); + }); + + it("should return the list of docs shared with user", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + permission: null, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const member = await buildUser({ + teamId: user.teamId, + }); + await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + const users = await document.$get("users"); + expect(users.length).toEqual(1); + const res = await server.post("/api/userMemberships.list", { + body: { + token: member.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).not.toBeFalsy(); + expect(body.data.documents).not.toBeFalsy(); + expect(body.data.documents).toHaveLength(1); + expect(body.data.memberships).not.toBeFalsy(); + expect(body.data.memberships).toHaveLength(1); + const sharedDoc = body.data.documents[0]; + expect(sharedDoc.id).toEqual(document.id); + expect(sharedDoc.id).toEqual(body.data.memberships[0].documentId); + expect(body.data.memberships[0].userId).toEqual(member.id); + expect(body.data.memberships[0].index).not.toBeFalsy(); + expect(body.policies).not.toBeFalsy(); + expect(body.policies).toHaveLength(2); + expect(body.policies[1].abilities).not.toBeFalsy(); + expect(body.policies[1].abilities.update).toEqual(true); + }); +}); + +describe("#userMemberships.update", () => { + it("should update the index", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + permission: null, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const member = await buildUser({ + teamId: user.teamId, + }); + const resp = await server.post("/api/documents.add_user", { + body: { + token: user.getJwtToken(), + id: document.id, + userId: member.id, + }, + }); + const respBody = await resp.json(); + expect(respBody.data).not.toBeFalsy(); + expect(respBody.data.memberships).not.toBeFalsy(); + expect(respBody.data.memberships).toHaveLength(1); + + const users = await document.$get("users"); + expect(users.length).toEqual(1); + const res = await server.post("/api/userMemberships.update", { + body: { + token: member.getJwtToken(), + id: respBody.data.memberships[0].id, + index: "V", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).not.toBeFalsy(); + expect(body.data.documentId).toEqual(document.id); + expect(body.data.userId).toEqual(member.id); + expect(body.data.index).toEqual("V"); + }); +}); diff --git a/server/routes/api/userMemberships/userMemberships.ts b/server/routes/api/userMemberships/userMemberships.ts new file mode 100644 index 000000000..be925a98b --- /dev/null +++ b/server/routes/api/userMemberships/userMemberships.ts @@ -0,0 +1,116 @@ +import Router from "koa-router"; + +import { Op, Sequelize } from "sequelize"; +import auth from "@server/middlewares/authentication"; +import { transaction } from "@server/middlewares/transaction"; +import validate from "@server/middlewares/validate"; +import { Document, Event, UserMembership } from "@server/models"; +import { authorize } from "@server/policies"; +import { + presentDocument, + presentMembership, + presentPolicies, +} from "@server/presenters"; +import { APIContext } from "@server/types"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "userMemberships.list", + auth(), + pagination(), + validate(T.UserMembershipsListSchema), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + + const memberships = await UserMembership.findAll({ + where: { + userId: user.id, + documentId: { + [Op.ne]: null, + }, + sourceId: { + [Op.eq]: null, + }, + }, + order: [ + Sequelize.literal('"user_permission"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const documentIds = memberships + .map((p) => p.documentId) + .filter(Boolean) as string[]; + const documents = await Document.scope([ + "withDrafts", + { method: ["withMembership", user.id] }, + { method: ["withCollectionPermissions", user.id] }, + ]).findAll({ + where: { + id: documentIds, + }, + }); + + const policies = presentPolicies(user, [...documents, ...memberships]); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + memberships: memberships.map(presentMembership), + documents: await Promise.all( + documents.map((document: Document) => presentDocument(document)) + ), + }, + policies, + }; + } +); + +router.post( + "userMemberships.update", + auth(), + validate(T.UserMembershipsUpdateSchema), + transaction(), + async (ctx: APIContext) => { + const { id, index } = ctx.input.body; + const { transaction } = ctx.state; + + const { user } = ctx.state.auth; + const membership = await UserMembership.findByPk(id, { + transaction, + rejectOnEmpty: true, + }); + authorize(user, "update", membership); + + membership.index = index; + await membership.save({ transaction }); + + await Event.create( + { + name: "userMemberships.update", + modelId: membership.id, + userId: membership.userId, + teamId: user.teamId, + actorId: user.id, + documentId: membership.documentId, + ip: ctx.request.ip, + data: { + index: membership.index, + }, + }, + { transaction } + ); + + ctx.body = { + data: presentMembership(membership), + policies: presentPolicies(user, [membership]), + }; + } +); + +export default router; diff --git a/server/routes/api/views/views.test.ts b/server/routes/api/views/views.test.ts index c165a4e75..93cceb64a 100644 --- a/server/routes/api/views/views.test.ts +++ b/server/routes/api/views/views.test.ts @@ -1,5 +1,5 @@ import { CollectionPermission } from "@shared/types"; -import { View, UserPermission } from "@server/models"; +import { View, UserMembership } from "@server/models"; import { buildAdmin, buildCollection, @@ -71,7 +71,7 @@ describe("#views.list", () => { }); collection.permission = null; await collection.save(); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, @@ -150,7 +150,7 @@ describe("#views.create", () => { }); collection.permission = null; await collection.save(); - await UserPermission.create({ + await UserMembership.create({ createdById: user.id, collectionId: collection.id, userId: user.id, diff --git a/server/types.ts b/server/types.ts index 0358edd2b..396de2424 100644 --- a/server/types.ts +++ b/server/types.ts @@ -125,8 +125,19 @@ export type UserEvent = BaseEvent & } ); +export type UserMembershipEvent = BaseEvent & { + name: "userMemberships.update"; + modelId: string; + userId: string; + documentId: string; + data: { + index: string | null; + }; +}; + export type DocumentEvent = BaseEvent & ( + | DocumentUserEvent | { name: | "documents.create" @@ -209,6 +220,13 @@ export type CollectionGroupEvent = BaseEvent & { data: { name: string; membershipId: string }; }; +export type DocumentUserEvent = BaseEvent & { + name: "documents.add_user" | "documents.remove_user"; + userId: string; + modelId: string; + documentId: string; +}; + export type CollectionEvent = BaseEvent & ( | CollectionUserEvent @@ -383,6 +401,7 @@ export type Event = | SubscriptionEvent | TeamEvent | UserEvent + | UserMembershipEvent | ViewEvent | WebhookSubscriptionEvent | NotificationEvent; diff --git a/shared/constants.ts b/shared/constants.ts index bb8b4be72..87d43f4de 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -11,6 +11,7 @@ export const Pagination = { defaultLimit: 25, defaultOffset: 0, maxLimit: 100, + sidebarLimit: 10, }; export const TeamPreferenceDefaults: TeamPreferences = { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index ee78c7916..398d91693 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -221,7 +221,7 @@ "Select a color": "Select a color", "Search": "Search", "Default access": "Default access", - "View and edit": "View and edit", + "Can edit": "Can edit", "View only": "View only", "No access": "No access", "Role": "Role", @@ -241,6 +241,38 @@ "Documents": "Documents", "Results": "Results", "No results for {{query}}": "No results for {{query}}", + "{{ userName }} was removed from the document": "{{ userName }} was removed from the document", + "Could not remove user": "Could not remove user", + "Permissions for {{ userName }} updated": "Permissions for {{ userName }} updated", + "Could not update user": "Could not update user", + "Has access through <2>parent": "Has access through <2>parent", + "Suspended": "Suspended", + "Invited": "Invited", + "Member": "Member", + "Leave": "Leave", + "Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed", + "Sorry, this link has already been used": "Sorry, this link has already been used", + "Public link copied to clipboard": "Public link copied to clipboard", + "Copy public link": "Copy public link", + "Web": "Web", + "Anyone with the link can access because the parent document, <2>{{documentTitle}}, is shared": "Anyone with the link can access because the parent document, <2>{{documentTitle}}, is shared", + "Allow anyone with the link to access": "Allow anyone with the link to access", + "Child documents are not shared, toggling sharing to enable": "Child documents are not shared, toggling sharing to enable", + "Publish to internet": "Publish to internet", + "{{ userName }} was invited to the document": "{{ userName }} was invited to the document", + "Done": "Done", + "Invite by name": "Invite by name", + "No matches": "No matches", + "All members": "All members", + "Everyone in the workspace": "Everyone in the workspace", + "Can view": "Can view", + "Everyone in the collection": "Everyone in the collection", + "You have full access": "You have full access", + "Created the document": "Created the document", + "Other people": "Other people", + "Other workspace members may have access": "Other workspace members may have access", + "This document may be shared with more workspace members through a parent document or collection you do not have access to": "This document may be shared with more workspace members through a parent document or collection you do not have access to", + "Access inherited from collection": "Access inherited from collection", "Logo": "Logo", "Move document": "Move document", "New doc": "New doc", @@ -250,9 +282,11 @@ "Empty": "Empty", "Go back": "Go back", "Go forward": "Go forward", + "Could not load shared documents": "Could not load shared documents", + "Shared with me": "Shared with me", + "Show more": "Show more", "Could not load starred documents": "Could not load starred documents", "Starred": "Starred", - "Show more": "Show more", "Up to date": "Up to date", "{{ releasesBehind }} versions behind": "{{ releasesBehind }} version behind", "{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind", @@ -396,7 +430,6 @@ "Delete group": "Delete group", "Group options": "Group options", "Member options": "Member options", - "Leave": "Leave", "New child document": "New child document", "New document in {{ collectionName }}": "New document in {{ collectionName }}", "New template": "New template", @@ -476,14 +509,10 @@ "Search people": "Search people", "No people matching your search": "No people matching your search", "No people left to add": "No people left to add", - "Permission": "Permission", "Active <1> ago": "Active <1> ago", "Never signed in": "Never signed in", - "Invited": "Invited", "{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection", - "Could not remove user": "Could not remove user", "{{ userName }} permissions were updated": "{{ userName }} permissions were updated", - "Could not update user": "Could not update user", "The {{ groupName }} group was removed from the collection": "The {{ groupName }} group was removed from the collection", "Could not remove group": "Could not remove group", "{{ groupName }} permissions were updated": "{{ groupName }} permissions were updated", @@ -567,23 +596,6 @@ "Deleted by {{userName}}": "Deleted by {{userName}}", "Observing {{ userName }}": "Observing {{ userName }}", "Backlinks": "Backlinks", - "Anyone with the link <1>can view this document": "Anyone with the link <1>can view this document", - "Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed", - "Sorry, this link has already been used": "Sorry, this link has already been used", - "Only members with permission can view": "Only members with permission can view", - "Publish to internet": "Publish to internet", - "Anyone with the link can view this document": "Anyone with the link can view this document", - "The shared link was last accessed {{ timeAgo }}.": "The shared link was last accessed {{ timeAgo }}.", - "This document is shared because the parent <2>{documentTitle} is publicly shared.": "This document is shared because the parent <2>{documentTitle} is publicly shared.", - "Share nested documents": "Share nested documents", - "Nested documents are publicly available": "Nested documents are publicly available", - "Nested documents are not shared": "Nested documents are not shared", - "Automatically redirect to the editor": "Automatically redirect to the editor", - "Users with edit permission will be redirected to the main app": "Users with edit permission will be redirected to the main app", - "All users see the same publicly shared view": "All users see the same publicly shared view", - "Custom link": "Custom link", - "The document will be accessible at <2>{{url}}": "The document will be accessible at <2>{{url}}", - "More options": "More options", "Close": "Close", "{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.", "Are you sure you want to delete the {{ documentTitle }} template?": "Are you sure you want to delete the {{ documentTitle }} template?", @@ -777,13 +789,13 @@ "Where do I find the file?": "Where do I find the file?", "In Notion, click Settings & Members in the left sidebar and open Settings. Look for the Export section, and click Export all workspace content. Choose HTML as the format for the best data compatability.": "In Notion, click Settings & Members in the left sidebar and open Settings. Look for the Export section, and click Export all workspace content. Choose HTML as the format for the best data compatability.", "Last active": "Last active", - "Suspended": "Suspended", "Shared": "Shared", "by {{ name }}": "by {{ name }}", "Last accessed": "Last accessed", "Shared by": "Shared by", "Date shared": "Date shared", "Shared nested": "Shared nested", + "Nested documents are publicly available": "Nested documents are publicly available", "Domain": "Domain", "Everyone": "Everyone", "Admins": "Admins", diff --git a/shared/types.ts b/shared/types.ts index e878559d6..61707e81f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -99,6 +99,11 @@ export enum CollectionPermission { Admin = "admin", } +export enum DocumentPermission { + Read = "read", + ReadWrite = "read_write", +} + export type IntegrationSettings = T extends IntegrationType.Embed ? { url: string } : T extends IntegrationType.Analytics diff --git a/yarn.lock b/yarn.lock index 5c3841f54..8fce4473b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1284,7 +1284,7 @@ "@dnd-kit/utilities" "^3.2.0" tslib "^2.0.0" -"@dnd-kit/utilities@^3.2.0", "@dnd-kit/utilities@^3.2.1", "@dnd-kit/utilities@^3.2.2": +"@dnd-kit/utilities@^3.2.1", "@dnd-kit/utilities@^3.2.2": version "3.2.2" resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b" integrity "sha1-WjK2rzVtxfdNYbN9b3EppAQM7Xs= sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==" @@ -10076,10 +10076,10 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" -outline-icons@^2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.7.0.tgz#aaacbb9ceba9f65f9152e977e14f4eaa28b45a5a" - integrity "sha1-qqy7nOup9l+RUul34U9Oqii0Wlo= sha512-Q17XgygWwGM1IaRO9Wd99Tk1wBnI01Sx3NktLxUwHZ1SN2RBVRIFUwf2J8ZtFo8pvJdH/BxOlX5L3NBTkcaKSg==" +outline-icons@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.0.0.tgz#75f56a7252f1605eb1fc9e21ddd0336e3dad4584" + integrity sha512-k3XCb19FDH6evjw7Ad9kRF/jHg4dGa3fYRD4S3kncjVnrvSlUDwT6GvNF+X1RSJ3Q3iJOziy3GH+DGkxEHsq4g== oy-vey@^0.12.1: version "0.12.1"