Individual document sharing with permissions (#5814)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Apoorv Mishra
2024-01-31 07:18:22 +05:30
committed by GitHub
parent 717c9b5d64
commit 1490c3a14b
91 changed files with 4004 additions and 1166 deletions

View File

@@ -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: <DuplicateIcon />,
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,
];

View File

@@ -81,12 +81,12 @@ const DocumentBreadcrumb: React.FC<Props> = ({
icon: <CollectionIcon collection={collection} expanded />,
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: "",
};
}

View File

@@ -114,7 +114,10 @@ export const LabelText = styled.div`
`;
export interface Props
extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"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
))}
<Outline focused={focused} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
<NativeTextarea

View File

@@ -2,32 +2,19 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
import { Permission } from "~/types";
export default function InputMemberPermissionSelect(
props: Partial<SelectProps>
props: Partial<SelectProps> & { permissions: Permission[] }
) {
const { t } = useTranslation();
return (
<Select
label={t("Permissions")}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("Admin"),
value: CollectionPermission.Admin,
},
]}
ariaLabel={t("Permission")}
options={props.permissions}
ariaLabel={t("Permissions")}
labelHidden
nude
{...props}

View File

@@ -107,6 +107,7 @@ const InputSelect = (props: Props) => {
);
React.useEffect(() => {
previousValue.current = value;
select.setSelectedValue(value);
}, [value]);

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import { CollectionPermission } from "@shared/types";
import { EmptySelectValue } from "~/types";
import InputSelect, { Props, Option } from "./InputSelect";
export default function InputSelectPermission(
@@ -17,7 +18,7 @@ export default function InputSelectPermission(
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
if (value === "no_access") {
if (value === EmptySelectValue) {
value = null;
}
@@ -31,7 +32,7 @@ export default function InputSelectPermission(
label={t("Default access")}
options={[
{
label: t("View and edit"),
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
@@ -40,11 +41,11 @@ export default function InputSelectPermission(
},
{
label: t("No access"),
value: "no_access",
value: EmptySelectValue,
},
]}
ariaLabel={t("Default access")}
value={value || "no_access"}
value={value || EmptySelectValue}
onChange={handleChange}
{...rest}
/>

View File

@@ -114,7 +114,11 @@ const Modal: React.FC<Props> = ({
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{title && (
<Text size="xlarge" weight="bold">
{title}
</Text>
)}
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</Content>
@@ -188,7 +192,7 @@ const Fullscreen = styled.div<FullscreenProps>`
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 32px;
padding: 8vh 12px;
${breakpoint("tablet")`
padding: 13vh 2rem 2rem;

View File

@@ -0,0 +1,141 @@
import { t } from "i18next";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
import Document from "~/models/Document";
import UserMembership from "~/models/UserMembership";
import LoadingIndicator from "~/components/LoadingIndicator";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { homePath } from "~/utils/routeHelpers";
import MemberListItem from "./MemberListItem";
type Props = {
/** Document to which team members are supposed to be invited */
document: Document;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users that have been invited to the document during the current editing session */
invitedInSession: string[];
};
function DocumentMembersList({ document, invitedInSession }: Props) {
const { users, userMemberships } = useStores();
const user = useCurrentUser();
const history = useHistory();
const can = usePolicy(document);
const { loading: loadingTeamMembers, request: fetchTeamMembers } = useRequest(
React.useCallback(
() => users.fetchPage({ limit: Pagination.defaultLimit }),
[users]
)
);
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: document.id,
limit: Pagination.defaultLimit,
}),
[userMemberships, document.id]
)
);
React.useEffect(() => {
void fetchTeamMembers();
void fetchDocumentMembers();
}, [fetchTeamMembers, fetchDocumentMembers]);
const handleRemoveUser = React.useCallback(
async (item) => {
try {
await userMemberships.delete({
documentId: document.id,
userId: item.id,
} as UserMembership);
if (item.id === user.id) {
history.push(homePath());
} else {
toast.success(
t(`{{ userName }} was removed from the document`, {
userName: item.name,
})
);
}
} catch (err) {
toast.error(t("Could not remove user"));
}
},
[history, userMemberships, user, document]
);
const handleUpdateUser = React.useCallback(
async (user, permission) => {
try {
await userMemberships.create({
documentId: document.id,
userId: user.id,
permission,
});
toast.success(
t(`Permissions for {{ userName }} updated`, {
userName: user.name,
})
);
} catch (err) {
toast.error(t("Could not update user"));
}
},
[userMemberships, document]
);
// Order newly added users first during the current editing session, on reload members are
// ordered by name
const members = React.useMemo(
() =>
orderBy(
document.members,
(user) =>
(invitedInSession.includes(user.id) ? "_" : "") +
user.name.toLowerCase(),
"asc"
),
[document.members, invitedInSession]
);
if (loadingTeamMembers || loadingDocumentMembers) {
return <LoadingIndicator />;
}
return (
<>
{members.map((item) => (
<MemberListItem
key={item.id}
user={item}
membership={item.getMembership(document)}
onRemove={() => handleRemoveUser(item)}
onUpdate={
can.manageUsers
? (permission) => handleUpdateUser(item, permission)
: undefined
}
onLeave={
item.id === user.id ? () => handleRemoveUser(item) : undefined
}
/>
))}
</>
);
}
export default observer(DocumentMembersList);

View File

@@ -0,0 +1,146 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { DocumentPermission } from "@shared/types";
import User from "~/models/User";
import UserMembership from "~/models/UserMembership";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import ListItem from "~/components/List/Item";
import { EmptySelectValue, Permission } from "~/types";
type Props = {
user: User;
membership?: UserMembership | undefined;
onAdd?: () => void;
onRemove?: () => void;
onLeave?: () => void;
onUpdate?: (permission: DocumentPermission) => void;
};
const MemberListItem = ({
user,
membership,
onRemove,
onLeave,
onUpdate,
}: Props) => {
const { t } = useTranslation();
const handleChange = React.useCallback(
(permission: DocumentPermission | typeof EmptySelectValue) => {
if (permission === EmptySelectValue) {
onRemove?.();
} else {
onUpdate?.(permission);
}
},
[onRemove, onUpdate]
);
const permissions: Permission[] = [
{
label: t("View only"),
value: DocumentPermission.Read,
},
{
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
},
{
label: t("No access"),
value: EmptySelectValue,
},
];
const currentPermission = permissions.find(
(p) => p.value === membership?.permission
);
if (!currentPermission) {
return null;
}
const disabled = !onUpdate && !onLeave;
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
return (
<StyledListItem
title={user.name}
image={
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
}
subtitle={
membership?.sourceId ? (
<Trans>
Has access through{" "}
<MaybeLink
// @ts-expect-error to prop does not exist on React.Fragment
to={membership.source?.document?.path ?? ""}
>
parent
</MaybeLink>
</Trans>
) : user.isSuspended ? (
t("Suspended")
) : user.isInvited ? (
t("Invited")
) : user.isViewer ? (
t("Viewer")
) : user.email ? (
user.email
) : (
t("Member")
)
}
actions={
disabled ? null : (
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={
onLeave
? [
currentPermission,
{
label: `${t("Leave")}`,
value: EmptySelectValue,
},
]
: permissions
}
value={membership?.permission}
onChange={handleChange}
/>
</div>
)
}
/>
);
};
export const InviteIcon = styled(PlusIcon)`
opacity: 0;
`;
export const StyledListItem = styled(ListItem).attrs({
small: true,
border: false,
})`
margin: 0 -16px;
padding: 6px 16px;
border-radius: 8px;
&:hover ${InviteIcon} {
opacity: 1;
}
`;
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
`;
export default observer(MemberListItem);

View File

@@ -0,0 +1,222 @@
import invariant from "invariant";
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
import Document from "~/models/Document";
import Share from "~/models/Share";
import Input, { NativeInput } from "~/components/Input";
import Switch from "~/components/Switch";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { AvatarSize } from "../Avatar/Avatar";
import CopyToClipboard from "../CopyToClipboard";
import NudeButton from "../NudeButton";
import { ResizingHeightContainer } from "../ResizingHeightContainer";
import Squircle from "../Squircle";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";
type Props = {
/** The document to share. */
document: Document;
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Ref to the Copy Link button */
copyButtonRef?: React.RefObject<HTMLButtonElement>;
onRequestClose?: () => void;
};
function PublicAccess({ document, share, sharedParent }: Props) {
const { shares } = useStores();
const { t } = useTranslation();
const theme = useTheme();
const [slugValidationError, setSlugValidationError] = React.useState("");
const [urlSlug, setUrlSlug] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const can = usePolicy(share);
const documentAbilities = usePolicy(document);
const canPublish = can.update && documentAbilities.share;
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 handleUrlSlugChange = React.useMemo(
() =>
debounce(async (ev) => {
if (!share) {
return;
}
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, share]
);
const handleCopied = React.useCallback(() => {
toast.success(t("Public link copied to clipboard"));
}, [t]);
const documentTitle = sharedParent?.documentTitle;
const shareUrl = sharedParent?.url
? `${sharedParent.url}${document.url}`
: share?.url ?? "";
const copyButton = (
<Tooltip tooltip={t("Copy public link")} delay={500} placement="top">
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<NudeButton type="button" disabled={!share} style={{ marginRight: 3 }}>
<CopyIcon color={theme.placeholder} size={18} />
</NudeButton>
</CopyToClipboard>
</Tooltip>
);
return (
<Wrapper>
<StyledListItem
title={t("Web")}
subtitle={
<>
{sharedParent && !document.isDraft ? (
<Trans>
Anyone with the link can access because the parent document,{" "}
<StyledLink to={`/doc/${sharedParent.documentId}`}>
{{ documentTitle }}
</StyledLink>
, is shared
</Trans>
) : (
<>
{t("Allow anyone with the link to access")}
{share?.published && !share.includeChildDocuments
? `. ${t(
"Child documents are not shared, toggling sharing to enable"
)}.`
: ""}
</>
)}
</>
}
image={
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GlobeIcon color={theme.background} size={18} />
</Squircle>
}
actions={
sharedParent && !document.isDraft ? null : (
<Switch
aria-label={t("Publish to internet")}
checked={share?.published ?? false}
onChange={handlePublishedChange}
disabled={!canPublish}
width={26}
height={14}
/>
)
}
/>
<ResizingHeightContainer>
{sharedParent?.published ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
</ShareLinkInput>
) : share?.published ? (
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlSlugChange}
error={slugValidationError}
defaultValue={urlSlug}
prefix={
<DomainPrefix
readOnly
onClick={() => inputRef.current?.focus()}
value={env.URL.replace(/https?:\/\//, "") + "/s/"}
/>
}
>
{copyButton}
</ShareLinkInput>
) : null}
</ResizingHeightContainer>
</Wrapper>
);
}
const Wrapper = styled.div`
margin-bottom: 8px;
`;
const DomainPrefix = styled(NativeInput)`
flex: 0 1 auto;
padding-right: 0 !important;
margin-right: -10px;
cursor: text;
color: ${s("placeholder")};
user-select: none;
`;
const ShareLinkInput = styled(Input)`
margin-top: 12px;
min-width: 100px;
flex: 1;
${NativeInput} {
padding: 4px 8px;
}
`;
const StyledLink = styled(Link)`
color: ${s("textSecondary")};
text-decoration: underline;
`;
export default observer(PublicAccess);

View File

@@ -0,0 +1,524 @@
import { AnimatePresence, m } from "framer-motion";
import { observer } from "mobx-react";
import {
BackIcon,
LinkIcon,
MoreIcon,
QuestionMarkIcon,
UserIcon,
} from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Share from "~/models/Share";
import User from "~/models/User";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles";
import { documentPath, urlify } from "~/utils/routeHelpers";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import ButtonSmall from "../ButtonSmall";
import Empty from "../Empty";
import CollectionIcon from "../Icons/CollectionIcon";
import Input, { NativeInput } from "../Input";
import NudeButton from "../NudeButton";
import Squircle from "../Squircle";
import Tooltip from "../Tooltip";
import DocumentMembersList from "./DocumentMemberList";
import { InviteIcon, StyledListItem } from "./MemberListItem";
import PublicAccess from "./PublicAccess";
type Props = {
/** The document to share. */
document: Document;
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
};
const presence = {
initial: {
opacity: 0,
width: 0,
marginRight: 0,
},
animate: {
opacity: 1,
width: "auto",
marginRight: 8,
transition: {
type: "spring",
duration: 0.2,
bounce: 0,
},
},
exit: {
opacity: 0,
width: 0,
marginRight: 0,
},
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>
memberships.fetchPage({ limit: 1, id: collection!.id })
);
React.useEffect(() => {
if (collection && !collection.permission) {
void request();
}
}, [collection]);
return collection
? collection.permission
? true
: users.inCollection(collection.id).length > 1
: false;
}
function SharePopover({
document,
share,
sharedParent,
onRequestClose,
visible,
}: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
const inputRef = React.useRef<HTMLInputElement>(null);
const { userMemberships } = useStores();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const linkButtonRef = React.useRef<HTMLButtonElement>(null);
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
const collectionSharingDisabled = document.collection?.sharing === false;
useKeyDown(
"Escape",
(ev) => {
ev.preventDefault();
ev.stopImmediatePropagation();
if (picker) {
hidePicker();
} else {
onRequestClose();
}
},
{
allowInInput: true,
}
);
// Fetch sharefocus the link button when the popover is opened
React.useEffect(() => {
if (visible) {
void document.share();
}
}, [document, hidePicker, visible]);
// Hide the picker when the popover is closed
React.useEffect(() => {
if (visible) {
hidePicker();
}
}, [hidePicker, visible]);
// Clear the query when picker is closed
React.useEffect(() => {
if (!picker) {
setQuery("");
}
}, [picker]);
const handleCopied = React.useCallback(() => {
onRequestClose();
timeout.current = setTimeout(() => {
toast.message(t("Link copied to clipboard"));
}, 100);
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, [onRequestClose, t]);
const handleInvite = React.useCallback(
async (user: User) => {
setInvitedInSession((prev) => [...prev, user.id]);
await userMemberships.create({
documentId: document.id,
userId: user.id,
});
toast.message(
t("{{ userName }} was invited to the document", { userName: user.name })
);
},
[t, userMemberships, document.id]
);
const handleQuery = React.useCallback(
(event) => {
showPicker();
setQuery(event.target.value);
},
[showPicker, setQuery]
);
const focusInput = React.useCallback(() => {
if (!picker) {
inputRef.current?.focus();
showPicker();
}
}, [picker, showPicker]);
const backButton = (
<>
{picker && (
<NudeButton key="back" as={m.button} {...presence} onClick={hidePicker}>
<BackIcon />
</NudeButton>
)}
</>
);
const doneButton = picker ? (
invitedInSession.length ? (
<ButtonSmall onClick={hidePicker} neutral>
{t("Done")}
</ButtonSmall>
) : null
) : (
<Tooltip tooltip={t("Copy link")} delay={500} placement="top">
<CopyToClipboard
text={urlify(documentPath(document))}
onCopy={handleCopied}
>
<NudeButton type="button" disabled={!share} ref={linkButtonRef}>
<LinkIcon size={20} />
</NudeButton>
</CopyToClipboard>
</Tooltip>
);
return (
<Wrapper>
{can.manageUsers &&
(isMobile ? (
<Flex align="center" style={{ marginBottom: 12 }} auto>
{backButton}
<Input
key="input"
placeholder={`${t("Invite by name")}`}
value={query}
onChange={handleQuery}
onClick={showPicker}
autoFocus
margin={0}
flex
>
{doneButton}
</Input>
</Flex>
) : (
<HeaderInput align="center" onClick={focusInput}>
<AnimatePresence initial={false}>
{backButton}
<NativeInput
key="input"
ref={inputRef}
placeholder={`${t("Invite by name")}`}
value={query}
onChange={handleQuery}
onClick={showPicker}
style={{ padding: "6px 0" }}
/>
{doneButton}
</AnimatePresence>
</HeaderInput>
))}
{picker && (
<div>
<Picker document={document} query={query} onInvite={handleInvite} />
</div>
)}
<div style={{ display: picker ? "none" : "block" }}>
<DocumentOtherAccessList document={document}>
<DocumentMembersList
document={document}
invitedInSession={invitedInSession}
/>
</DocumentOtherAccessList>
{team.sharing && can.share && !collectionSharingDisabled && (
<>
{document.members.length ? <Separator /> : null}
<PublicAccess
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={onRequestClose}
/>
</>
)}
</div>
</Wrapper>
);
}
const Picker = observer(
({
document,
query,
onInvite,
}: {
document: Document;
query: string;
onInvite: (user: User) => Promise<void>;
}) => {
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) => (
<StyledListItem
key={suggestion.id}
onClick={() => 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={
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
}
actions={<InviteIcon />}
/>
))}
</>
) : (
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
);
}
);
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 ? (
<StyledListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip>
{collection?.permission === CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<StyledListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<StyledListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
{children}
</>
) : document.isDraft ? (
<>
<StyledListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy.name}
actions={
<AccessTooltip tooltip={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{children}
</>
) : (
<>
{children}
<StyledListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
tooltip={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</>
);
}
);
const AccessTooltip = ({
children,
tooltip,
}: {
children?: React.ReactNode;
tooltip?: string;
}) => {
const { t } = useTranslation();
return (
<Flex align="center" gap={2}>
<Text type="secondary" size="small" as="span">
{children}
</Text>
<Tooltip tooltip={tooltip ?? t("Access inherited from collection")}>
<QuestionMarkIcon size={18} />
</Tooltip>
</Flex>
);
};
// 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);

View File

@@ -0,0 +1,3 @@
import SharePopover from "./SharePopover";
export default SharePopover;

View File

@@ -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() {
/>
)}
</Section>
<Section>
<SharedWithMe />
</Section>
<Section>
<Starred />
</Section>

View File

@@ -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<RefHandle>(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),

View File

@@ -0,0 +1,7 @@
import * as React from "react";
const SharedContext = React.createContext<boolean | undefined>(undefined);
export const useSharedContext = () => React.useContext(SharedContext);
export default SharedContext;

View File

@@ -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<UserMembership>(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 (
<SharedContext.Provider value={true}>
<Flex column>
<Header id="shared" title={t("Shared with me")}>
<Relative>
{reorderMonitor.isDragging && (
<DropCursor
isActiveDrop={reorderMonitor.isOverCursor}
innerRef={dropToReorderRef}
position="top"
/>
)}
{user.memberships
.slice(0, page * Pagination.sidebarLimit)
.map((membership) => (
<SharedWithMeLink
key={membership.id}
userMembership={membership}
/>
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={loading}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Flex>
</SharedContext.Provider>
);
}
export default observer(SharedWithMe);

View File

@@ -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<HTMLButtonElement>) => {
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 (
<>
<Draggable
key={userMembership.id}
ref={draggableRef}
$isDragging={isDragging}
>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { starred: true },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
icon={icon}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{reorderMonitor.isDragging && (
<DropCursor
isActiveDrop={reorderMonitor.isOverCursor}
innerRef={dropToReorderRef}
/>
)}
</Relative>
</>
);
}
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);

View File

@@ -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,
<StarredIcon color={theme.yellow} />
);
const [{ isDragging }, draggableRef] = useDragStar(star);
const [reorderStarMonitor, dropToReorderRef] = useDropToReorderStar(getIndex);
const [createStarMonitor, dropToStarRef] = useDropToCreateStar(getIndex);

View File

@@ -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,
<StarredIcon color={theme.yellow} />
);
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",
}),
});
}

View File

@@ -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 ?? <DocumentIcon />;
if (documentId) {
const document = documents.get(documentId);
if (document) {
return {
label: document.titleWithDefault,
icon: document.emoji ? (
<EmojiIcon emoji={document.emoji} />
) : (
<StarredIcon color={theme.yellow} />
),
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
};
}
}
@@ -36,6 +38,6 @@ export function useStarLabelAndIcon({ documentId, collectionId }: Star) {
return {
label: "",
icon: <StarredIcon color={theme.yellow} />,
icon,
};
}

View File

@@ -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>`
? 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"

View File

@@ -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<Props> {
pins,
stars,
memberships,
userMemberships,
policies,
comments,
subscriptions,
@@ -235,6 +237,10 @@ class WebsocketProvider extends React.Component<Props> {
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<Props> {
}
);
// received when a user is given access to a document
this.socket.on(
"documents.add_user",
(event: PartialWithId<UserMembership>) => {
userMemberships.add(event);
}
);
this.socket.on(
"documents.remove_user",
(event: PartialWithId<UserMembership>) => {
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<Comment>) => {
comments.add(event);
});
@@ -367,7 +411,7 @@ class WebsocketProvider extends React.Component<Props> {
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<Props> {
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<Props> {
);
this.socket.on("users.demote", async (event: PartialWithId<User>) => {
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<UserMembership>) => {
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) => {

View File

@@ -16,7 +16,7 @@ function MemberMenu({ user, onRemove }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const menu = useMenuState({
modal: true,
modal: false,
});
return (

View File

@@ -45,7 +45,7 @@ export default class Collection extends ParanoidModel {
@Field
@observable
permission: CollectionPermission | void;
permission?: CollectionPermission;
@Field
@observable

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) => (
<GroupListItem
group={group}
showAvatar
renderActions={({ openMembersModal }) => (
<>
<InputMemberPermissionSelect
value={
collectionGroupMembership
? collectionGroupMembership.permission
: undefined
}
onChange={onUpdate}
/>
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
</>
)}
/>
);
}: Props) => {
const { t } = useTranslation();
return (
<GroupListItem
group={group}
showAvatar
renderActions={({ openMembersModal }) => (
<>
<InputMemberPermissionSelect
value={collectionGroupMembership?.permission}
onChange={onUpdate}
permissions={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("Admin"),
value: CollectionPermission.Admin,
},
]}
/>
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
</>
)}
/>
);
};
export default CollectionGroupMemberListItem;

View File

@@ -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 = ({
<Flex align="center" gap={8}>
{onUpdate && (
<InputMemberPermissionSelect
value={membership ? membership.permission : undefined}
permissions={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("Admin"),
value: CollectionPermission.Admin,
},
]}
value={membership?.permission}
onChange={onUpdate}
disabled={!canEdit}
/>

View File

@@ -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 <Loading location={props.location} />;
}
if (response && searchParams.get("edit") === "true" && can.update) {
return <Redirect to={response.document.url} />;
}
return (
<>
<Helmet>

View File

@@ -337,6 +337,7 @@ const Title = styled(ContentEditable)<TitleProps>`
&::placeholder {
color: ${s("placeholder")};
-webkit-text-fill-color: ${s("placeholder")};
opacity: 1;
}
&:focus-within,

View File

@@ -257,16 +257,11 @@ function DocumentHeader({
/>
</Action>
)}
{!isEditing &&
!isDeleted &&
!isRevision &&
!isTemplate &&
!isMobile &&
document.collectionId && (
<Action>
<ShareButton document={document} />
</Action>
)}
{!isEditing && !isRevision && !isMobile && can.update && (
<Action>
<ShareButton document={document} />
</Action>
)}
{(isEditing || isTemplate) && (
<Action>
<Tooltip
@@ -333,17 +328,19 @@ function DocumentHeader({
</Tooltip>
</Action>
)}
<Action>
<Button
action={publishDocument}
context={context}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
>
{document.collectionId ? t("Publish") : `${t("Publish")}`}
</Button>
</Action>
{can.publish && (
<Action>
<Button
action={publishDocument}
context={context}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
>
{document.collectionId ? t("Publish") : `${t("Publish")}`}
</Button>
</Action>
)}
{!isDeleted && <Separator />}
<Action>
<DocumentMenu

View File

@@ -1,15 +1,14 @@
import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import Tooltip from "~/components/Tooltip";
import SharePopover from "~/components/Sharing";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import SharePopover from "./SharePopover";
type Props = {
document: Document;
@@ -23,7 +22,8 @@ function ShareButton({ document }: Props) {
const sharedParent = shares.getByDocumentParents(document.id);
const domain = share?.domain || sharedParent?.domain;
const isPubliclyShared =
team.sharing &&
team.sharing !== false &&
document.collection?.sharing !== false &&
(share?.published || (sharedParent?.published && !document.isDraft));
const popover = usePopoverState({
@@ -36,32 +36,17 @@ function ShareButton({ document }: Props) {
<>
<PopoverDisclosure {...popover}>
{(props) => (
<Tooltip
tooltip={
isPubliclyShared ? (
<Trans>
Anyone with the link <br />
can view this document
</Trans>
) : (
""
)
}
delay={500}
placement="bottom"
<Button
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
neutral
{...props}
>
<Button
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
neutral
{...props}
>
{t("Share")} {domain && <>&middot; {domain}</>}
</Button>
</Tooltip>
{t("Share")} {domain && <>&middot; {domain}</>}
</Button>
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Share")}>
<Popover {...popover} aria-label={t("Share")} width={400}>
<SharePopover
document={document}
share={share}

View File

@@ -1,405 +0,0 @@
import invariant from "invariant";
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { ExpandedIcon, GlobeIcon, PadlockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
import Document from "~/models/Document";
import Share from "~/models/Share";
import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import Input, { StyledText } from "~/components/Input";
import Notice from "~/components/Notice";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
type Props = {
/** The document to share. */
document: Document;
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Whether to hide the title. */
hideTitle?: boolean;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => 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<ReturnType<typeof setTimeout>>();
const buttonRef = React.useRef<HTMLButtonElement>(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 (
<Text type="secondary">
{t("Only members with permission can view")}
</Text>
);
}
return (
<SwitchWrapper>
<Switch
id="published"
label={t("Publish to internet")}
onChange={handlePublishedChange}
checked={share ? share.published : false}
disabled={!share}
/>
<SwitchLabel>
<SwitchText>
{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,
}),
})}
</>
)}
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
);
};
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 && (
<Heading>
{isPubliclyShared ? (
<GlobeIcon size={28} />
) : (
<PadlockIcon size={28} />
)}
<span>{t("Share this document")}</span>
</Heading>
)}
{sharedParent && !document.isDraft && (
<NoticeWrapper>
<Notice>
<Trans>
This document is shared because the parent{" "}
<StyledLink to={`/doc/${sharedParent.documentId}`}>
{documentTitle}
</StyledLink>{" "}
is publicly shared.
</Trans>
</Notice>
</NoticeWrapper>
)}
{canPublish && !sharedParent?.published && (
<PublishToInternet canPublish />
)}
{canPublish && share?.published && !document.isDraft && (
<SwitchWrapper>
<Switch
id="includeChildDocuments"
label={t("Share nested documents")}
onChange={handleChildDocumentsChange}
checked={share ? share.includeChildDocuments : false}
disabled={!share}
/>
<SwitchLabel>
<SwitchText>
{share.includeChildDocuments
? t("Nested documents are publicly available")
: t("Nested documents are not shared")}
.
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)}
{expandedOptions && (
<>
{canPublish && sharedParent?.published && (
<>
<Separator />
<PublishToInternet canPublish />
</>
)}
<Separator />
<SwitchWrapper>
<Switch
id="enableEditMode"
label={t("Automatically redirect to the editor")}
onChange={({ currentTarget: { checked } }) =>
setIsEditMode(checked)
}
checked={isEditMode}
disabled={!share}
/>
<SwitchLabel>
<SwitchText>
{isEditMode
? t(
"Users with edit permission will be redirected to the main app"
)
: t("All users see the same publicly shared view")}
.
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
<Separator />
<SwitchWrapper>
<Input
type="text"
label={t("Custom link")}
placeholder="a-unique-link"
onChange={handleUrlSlugChange}
error={slugValidationError}
defaultValue={urlSlug}
/>
{!slugValidationError && urlSlug && (
<DocumentLinkPreview type="secondary">
<Trans>
The document will be accessible at{" "}
<a href={shareUrl} target="_blank" rel="noopener noreferrer">
{{ url }}
</a>
</Trans>
</DocumentLinkPreview>
)}
</SwitchWrapper>
</>
)}
<Flex justify="space-between" style={{ marginBottom: 8 }}>
{expandedOptions || !canPublish ? (
<span />
) : (
<MoreOptionsButton
icon={<ExpandedIcon />}
onClick={() => setExpandedOptions(true)}
neutral
borderOnHover
>
{t("More options")}
</MoreOptionsButton>
)}
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<Button
type="submit"
disabled={!share || slugValidationError}
ref={buttonRef}
>
{t("Copy link")}
</Button>
</CopyToClipboard>
</Flex>
</>
);
}
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);

View File

@@ -16,7 +16,7 @@ export default class MembershipsStore extends Store<Membership> {
@action
fetchPage = async (
params: PaginationParams | undefined
params: (PaginationParams & { id?: string }) | undefined
): Promise<Membership[]> => {
this.isFetching = true;

View File

@@ -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");

View File

@@ -21,14 +21,13 @@ export default class StarsStore extends Store<Star> {
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;
}

View File

@@ -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<UserMembership> {
actions = [
RPCAction.List,
RPCAction.Create,
RPCAction.Delete,
RPCAction.Update,
];
constructor(rootStore: RootStore) {
super(rootStore, UserMembership);
}
@action
fetchPage = async (
params?: PaginationParams | undefined
): Promise<UserMembership[]> => {
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<UserMembership[]> => {
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<UserMembership>) {
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;
});
}
}

View File

@@ -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<User> {
}
};
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,

View File

@@ -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<T> = Partial<T> & { 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<Pin>
| PartialWithId<Star>
| PartialWithId<FileOperation>
| PartialWithId<UserMembership>
| 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<C> = {
[Property in keyof C as C[Property] extends JSONValue

View File

@@ -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",

View File

@@ -39,6 +39,8 @@ const WEBHOOK_EVENTS = {
"documents.move",
"documents.update",
"documents.title_change",
"documents.add_user",
"documents.remove_user",
],
collections: [
"collections.create",

View File

@@ -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<Props> {
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<Props> {
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<Props> {
subscription: WebhookSubscription,
event: CollectionUserEvent
): Promise<void> {
const model = await UserPermission.scope([
const model = await UserMembership.scope([
"withUser",
"withCollection",
]).findOne({
@@ -513,6 +521,33 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
});
}
private async handleDocumentUserEvent(
subscription: WebhookSubscription,
event: DocumentUserEvent
): Promise<void> {
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

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

@@ -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");
},
};

View File

@@ -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");
},
};

View File

@@ -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)

View File

@@ -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<

View File

@@ -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<ScopeOptions> = {
method: ["withViews", userId],
};
return this.scope(["defaultScope", collectionScope, viewScope]);
const membershipScope: Readonly<ScopeOptions> = {
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

View File

@@ -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",

View File

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

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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);
});
});
});

View File

@@ -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<UserMembership>,
Partial<InferCreationAttributes<UserMembership>>
> {
@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<UserMembership>
): Promise<UserMembership[]> {
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<UserMembership>
) {
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<UserMembership>
) {
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<UserMembership>
) {
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;

View File

@@ -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<UserPermission>,
Partial<InferCreationAttributes<UserPermission>>
> {
@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;

View File

@@ -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);
});
});

View File

@@ -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<Results>(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<RankedDocument[]>;
const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count
replacements: queryReplacements,
where,
}) as any as Promise<number>;
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<Document[]> {
const { limit = 15, offset = 0 } = options;
const where = await this.buildWhere(user, options);
const where: WhereOptions<Document> = {
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<Results>(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<RankedDocument[]>;
const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count
subQuery: false,
include,
replacements: queryReplacements,
where,
}) as any as Promise<number>;
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<Document> = {
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 {

View File

@@ -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";

View File

@@ -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,

View File

@@ -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],

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -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<string, boolean>;
@@ -58,6 +60,7 @@ export function serialize(
| User
| Group
| Notification
| UserMembership
| null
): Policy {
const output = {};

View File

@@ -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
);

View File

@@ -41,6 +41,7 @@ async function presentDocument(
collectionId: undefined,
parentDocumentId: undefined,
lastViewedAt: undefined,
isCollectionDeleted: await document.isCollectionDeleted(),
};
if (!!document.views && document.views.length > 0) {

View File

@@ -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,
};
}

View File

@@ -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,
},

View File

@@ -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<string[]> {
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;
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<UserPermission> = {
let where: WhereOptions<UserMembership> = {
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,

View File

@@ -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);
});
});

View File

@@ -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<ScopeOptions> = {
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<T.DocumentsAddUserReq>) => {
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<T.DocumentsRemoveUserReq>) => {
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<T.DocumentsMembershipsReq>) => {
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<UserMembership> = {
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;

View File

@@ -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<typeof DocumentsUsersSchema>;
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<typeof DocumentsAddUserSchema>;
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<typeof DocumentsRemoveUserSchema>;
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
>;

View File

@@ -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());

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
export { default } from "./userMemberships";

View File

@@ -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<typeof UserMembershipsListSchema>;
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
>;

View File

@@ -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");
});
});

View File

@@ -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<T.UserMembershipsListReq>) => {
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<T.UserMembershipsUpdateReq>) => {
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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -11,6 +11,7 @@ export const Pagination = {
defaultLimit: 25,
defaultOffset: 0,
maxLimit: 100,
sidebarLimit: 10,
};
export const TeamPreferenceDefaults: TeamPreferences = {

View File

@@ -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</2>": "Has access through <2>parent</2>",
"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}}</2>, is shared": "Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, 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 <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
"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></1> ago": "Active <1></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></1>can view this document": "Anyone with the link <1></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}</2> is publicly shared.": "This document is shared because the parent <2>{documentTitle}</2> 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}}</2>": "The document will be accessible at <2>{{url}}</2>",
"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 <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
@@ -777,13 +789,13 @@
"Where do I find the file?": "Where do I find the file?",
"In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> 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",

View File

@@ -99,6 +99,11 @@ export enum CollectionPermission {
Admin = "admin",
}
export enum DocumentPermission {
Read = "read",
ReadWrite = "read_write",
}
export type IntegrationSettings<T> = T extends IntegrationType.Embed
? { url: string }
: T extends IntegrationType.Analytics

View File

@@ -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"