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:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -107,6 +107,7 @@ const InputSelect = (props: Props) => {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
previousValue.current = value;
|
||||
select.setSelectedValue(value);
|
||||
}, [value]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
141
app/components/Sharing/DocumentMemberList.tsx
Normal file
141
app/components/Sharing/DocumentMemberList.tsx
Normal 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);
|
||||
146
app/components/Sharing/MemberListItem.tsx
Normal file
146
app/components/Sharing/MemberListItem.tsx
Normal 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);
|
||||
222
app/components/Sharing/PublicAccess.tsx
Normal file
222
app/components/Sharing/PublicAccess.tsx
Normal 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);
|
||||
524
app/components/Sharing/SharePopover.tsx
Normal file
524
app/components/Sharing/SharePopover.tsx
Normal 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);
|
||||
3
app/components/Sharing/index.tsx
Normal file
3
app/components/Sharing/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import SharePopover from "./SharePopover";
|
||||
|
||||
export default SharePopover;
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
7
app/components/Sidebar/components/SharedContext.ts
Normal file
7
app/components/Sidebar/components/SharedContext.ts
Normal 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;
|
||||
89
app/components/Sidebar/components/SharedWithMe.tsx
Normal file
89
app/components/Sidebar/components/SharedWithMe.tsx
Normal 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);
|
||||
158
app/components/Sidebar/components/SharedWithMeLink.tsx
Normal file
158
app/components/Sidebar/components/SharedWithMeLink.tsx
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ function MemberMenu({ user, onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
modal: false,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -45,7 +45,7 @@ export default class Collection extends ParanoidModel {
|
||||
|
||||
@Field
|
||||
@observable
|
||||
permission: CollectionPermission | void;
|
||||
permission?: CollectionPermission;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
75
app/models/UserMembership.ts
Normal file
75
app/models/UserMembership.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -337,6 +337,7 @@ const Title = styled(ContentEditable)<TitleProps>`
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
-webkit-text-fill-color: ${s("placeholder")};
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-within,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && <>· {domain}</>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{t("Share")} {domain && <>· {domain}</>}
|
||||
</Button>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
|
||||
<Popover {...popover} aria-label={t("Share")}>
|
||||
<Popover {...popover} aria-label={t("Share")} width={400}>
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
104
app/stores/UserMembershipsStore.ts
Normal file
104
app/stores/UserMembershipsStore.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
20
app/types.ts
20
app/types.ts
@@ -1,12 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import { Location, LocationDescriptor } from "history";
|
||||
import { TFunction } from "i18next";
|
||||
import { JSONValue } from "@shared/types";
|
||||
import {
|
||||
JSONValue,
|
||||
CollectionPermission,
|
||||
DocumentPermission,
|
||||
} from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "./models/Document";
|
||||
import FileOperation from "./models/FileOperation";
|
||||
import Pin from "./models/Pin";
|
||||
import Star from "./models/Star";
|
||||
import UserMembership from "./models/UserMembership";
|
||||
|
||||
export type PartialWithId<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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -39,6 +39,8 @@ const WEBHOOK_EVENTS = {
|
||||
"documents.move",
|
||||
"documents.update",
|
||||
"documents.title_change",
|
||||
"documents.add_user",
|
||||
"documents.remove_user",
|
||||
],
|
||||
collections: [
|
||||
"collections.create",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
252
server/models/UserMembership.ts
Normal file
252
server/models/UserMembership.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
9
server/policies/userMembership.ts
Normal file
9
server/policies/userMembership.ts
Normal 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
|
||||
);
|
||||
@@ -41,6 +41,7 @@ async function presentDocument(
|
||||
collectionId: undefined,
|
||||
parentDocumentId: undefined,
|
||||
lastViewedAt: undefined,
|
||||
isCollectionDeleted: await document.isCollectionDeleted(),
|
||||
};
|
||||
|
||||
if (!!document.views && document.views.length > 0) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
server/routes/api/userMemberships/index.ts
Normal file
1
server/routes/api/userMemberships/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./userMemberships";
|
||||
20
server/routes/api/userMemberships/schema.ts
Normal file
20
server/routes/api/userMemberships/schema.ts
Normal 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
|
||||
>;
|
||||
110
server/routes/api/userMemberships/userMemberships.test.ts
Normal file
110
server/routes/api/userMemberships/userMemberships.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
116
server/routes/api/userMemberships/userMemberships.ts
Normal file
116
server/routes/api/userMemberships/userMemberships.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ export const Pagination = {
|
||||
defaultLimit: 25,
|
||||
defaultOffset: 0,
|
||||
maxLimit: 100,
|
||||
sidebarLimit: 10,
|
||||
};
|
||||
|
||||
export const TeamPreferenceDefaults: TeamPreferences = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -1284,7 +1284,7 @@
|
||||
"@dnd-kit/utilities" "^3.2.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/utilities@^3.2.0", "@dnd-kit/utilities@^3.2.1", "@dnd-kit/utilities@^3.2.2":
|
||||
"@dnd-kit/utilities@^3.2.1", "@dnd-kit/utilities@^3.2.2":
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b"
|
||||
integrity "sha1-WjK2rzVtxfdNYbN9b3EppAQM7Xs= sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="
|
||||
@@ -10076,10 +10076,10 @@ os-tmpdir@~1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||
integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
|
||||
|
||||
outline-icons@^2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.7.0.tgz#aaacbb9ceba9f65f9152e977e14f4eaa28b45a5a"
|
||||
integrity "sha1-qqy7nOup9l+RUul34U9Oqii0Wlo= sha512-Q17XgygWwGM1IaRO9Wd99Tk1wBnI01Sx3NktLxUwHZ1SN2RBVRIFUwf2J8ZtFo8pvJdH/BxOlX5L3NBTkcaKSg=="
|
||||
outline-icons@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.0.0.tgz#75f56a7252f1605eb1fc9e21ddd0336e3dad4584"
|
||||
integrity sha512-k3XCb19FDH6evjw7Ad9kRF/jHg4dGa3fYRD4S3kncjVnrvSlUDwT6GvNF+X1RSJ3Q3iJOziy3GH+DGkxEHsq4g==
|
||||
|
||||
oy-vey@^0.12.1:
|
||||
version "0.12.1"
|
||||
|
||||
Reference in New Issue
Block a user