feat: Invite groups to documents (#7275)

This commit is contained in:
Tom Moor
2024-09-01 09:51:52 -04:00
committed by GitHub
parent fefb9200f1
commit f61689abdc
96 changed files with 2157 additions and 914 deletions

View File

@@ -104,9 +104,9 @@ export const createDocument = createAction({
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
perform: ({ activeCollectionId, inStarredSection }) =>
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
sidebarContext,
}),
});
@@ -121,11 +121,11 @@ export const createDocumentFromTemplate = createAction({
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.template &&
stores.policies.abilities(currentTeamId).createDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
{
starred: inStarredSection,
sidebarContext,
}
),
});
@@ -141,9 +141,9 @@ export const createNestedDocument = createAction({
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeDocumentId, inStarredSection }) =>
perform: ({ activeDocumentId, sidebarContext }) =>
history.push(newNestedDocumentPath(activeDocumentId), {
starred: inStarredSection,
sidebarContext,
}),
});

View File

@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
import Avatar from "./Avatar";
type Props = {
user: User;

View File

@@ -0,0 +1,35 @@
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import Group from "~/models/Group";
import { AvatarSize } from "../Avatar";
type Props = {
/** The group to show an avatar for */
group: Group;
/** The size of the icon, 24px is default to match standard avatars */
size?: number;
/** The color of the avatar */
color?: string;
/** The background color of the avatar */
backgroundColor?: string;
className?: string;
};
export function GroupAvatar({
color,
backgroundColor,
size = AvatarSize.Medium,
className,
}: Props) {
const theme = useTheme();
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
</Squircle>
);
}

View File

@@ -1,6 +1,7 @@
import Avatar from "./Avatar";
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { AvatarWithPresence };
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
export default Avatar;
export type { IAvatar };

View File

@@ -7,7 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
import { AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";

View File

@@ -16,7 +16,7 @@ import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";

View File

@@ -3,9 +3,8 @@ import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { AvatarSize } from "./Avatar/Avatar";
type Props = {
users: User[];

View File

@@ -13,7 +13,6 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
@@ -26,15 +25,11 @@ type Props = {
};
function GroupListItem({ group, showFacepile, renderActions }: Props) {
const { groupUsers } = useStores();
const { t } = useTranslation();
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
useBoolean();
const memberCount = group.memberCount;
const membershipsInGroup = groupUsers.inGroup(group.id);
const users = membershipsInGroup
.slice(0, MAX_AVATAR_DISPLAY)
.map((gm) => gm.user);
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
const overflow = memberCount - users.length;
return (

View File

@@ -1,8 +1,8 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { Preview, Title, Info, Card, CardContent } from "./Components";

View File

@@ -1,8 +1,8 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";

View File

@@ -9,8 +9,7 @@ import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { hover, truncateMultiline } from "~/styles";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import { Avatar, AvatarSize } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";

View File

@@ -1,15 +1,14 @@
import { observer } from "mobx-react";
import { GroupIcon, UserIcon } from "outline-icons";
import { UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import InputSelectPermission from "~/components/InputSelectPermission";
import LoadingIndicator from "~/components/LoadingIndicator";
import Scrollable from "~/components/Scrollable";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
@@ -17,6 +16,7 @@ import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
type Props = {
/** Collection to which team members are supposed to be invited */
@@ -35,21 +35,31 @@ export const AccessControlList = observer(
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships, data: membershipData } = useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships, data: groupMembershipData } =
const { request: fetchMemberships, loading: membershipLoading } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ id: collectionId }),
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ collectionId }),
[groupMemberships, collectionId]
)
);
const groupMembershipsInCollection =
groupMemberships.inCollection(collectionId);
const membershipsInCollection = memberships.inCollection(collectionId);
const hasMemberships =
groupMembershipsInCollection.length > 0 ||
membershipsInCollection.length > 0;
const showLoading =
!hasMemberships && (membershipLoading || groupMembershipLoading);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
@@ -95,132 +105,142 @@ export const AccessControlList = observer(
hiddenScrollbars
style={{ maxHeight }}
>
{(!membershipData || !groupMembershipData) && <LoadingIndicator />}
<ListItem
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={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
style={{ margin: 0 }}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={collection?.permission}
labelHidden
nude
/>
</div>
}
/>
{groupMemberships
.inCollection(collection.id)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
{showLoading ? (
<Placeholder count={2} />
) : (
<>
<ListItem
key={membership.id}
image={
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GroupIcon color={theme.background} size={16} />
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
<InputSelectPermission
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission: CollectionPermission | typeof EmptySelectValue
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={membership.permission}
value={collection?.permission}
labelHidden
nude
/>
</div>
}
/>
))}
{memberships
.inCollection(collection.id)
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") + a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
{groupMembershipsInCollection
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") +
a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<GroupAvatar
group={membership.group}
backgroundColor={theme.modalBackground}
/>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission: CollectionPermission | typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
))}
))}
{membershipsInCollection
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") +
a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
/>
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
))}
</>
)}
</ScrollableContainer>
);
}

View File

@@ -9,7 +9,7 @@ import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import NudeButton from "~/components/NudeButton";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
@@ -357,7 +357,6 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
showGroups
/>
)}

View File

@@ -11,7 +11,6 @@ import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Share from "~/models/Share";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -20,12 +19,12 @@ import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Avatar from "../../Avatar";
import { AvatarSize } from "../../Avatar/Avatar";
import { Avatar, AvatarSize } from "../../Avatar";
import CollectionIcon from "../../Icons/CollectionIcon";
import Tooltip from "../../Tooltip";
import { Separator } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import DocumentMemberList from "./DocumentMemberList";
import PublicAccess from "./PublicAccess";
@@ -58,10 +57,12 @@ export const AccessControlList = observer(
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
const { userMemberships } = useStores();
const { userMemberships, groupMemberships } = useStores();
const collectionSharingDisabled = document.collection?.sharing === false;
const team = useCurrentTeam();
const can = usePolicy(document);
const canCollection = usePolicy(collection);
const documentId = document.id;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const { maxHeight, calcMaxHeight } = useMaxHeight({
@@ -70,21 +71,36 @@ export const AccessControlList = observer(
margin: 24,
});
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
const { loading: userMembershipLoading, request: fetchUserMemberships } =
useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: document.id,
id: documentId,
limit: Pagination.defaultLimit,
}),
[userMemberships, document.id]
[userMemberships, documentId]
)
);
const { loading: groupMembershipLoading, request: fetchGroupMemberships } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ documentId }),
[groupMemberships, documentId]
)
);
const hasMemberships =
groupMemberships.inDocument(documentId)?.length > 0 ||
document.members.length > 0;
const showLoading =
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
React.useEffect(() => {
void fetchDocumentMembers();
}, [fetchDocumentMembers]);
void fetchUserMemberships();
void fetchGroupMemberships();
}, [fetchUserMemberships, fetchGroupMemberships]);
React.useEffect(() => {
calcMaxHeight();
@@ -96,84 +112,92 @@ export const AccessControlList = observer(
hiddenScrollbars
style={{ maxHeight }}
>
{loadingDocumentMembers && <LoadingIndicator />}
{collection ? (
<>
{collection.permission ? (
<ListItem
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 ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
{showLoading ? (
<Placeholder />
) : (
<>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
<ListItem
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
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
{collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
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 ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
}
/>
</>
) : document.isDraft ? (
<>
<ListItem
image={
<Avatar model={document.createdBy} showBorder={false} />
}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : (
<>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
<ListItem
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
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</>
)}
{team.sharing && can.share && !collectionSharingDisabled && visible && (

View File

@@ -1,16 +1,23 @@
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useTranslation, Trans } from "react-i18next";
import { Link, useHistory } from "react-router-dom";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import { DocumentPermission } from "@shared/types";
import Document from "~/models/Document";
import UserMembership from "~/models/UserMembership";
import { GroupAvatar } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { homePath } from "~/utils/routeHelpers";
import MemberListItem from "./DocumentMemberListItem";
import { ListItem } from "../components/ListItem";
import DocumentMemberListItem from "./DocumentMemberListItem";
type Props = {
/** Document to which team members are supposed to be invited */
@@ -22,12 +29,13 @@ type Props = {
};
function DocumentMembersList({ document, invitedInSession }: Props) {
const { userMemberships } = useStores();
const { userMemberships, groupMemberships } = useStores();
const user = useCurrentUser();
const history = useHistory();
const can = usePolicy(document);
const { t } = useTranslation();
const theme = useTheme();
const handleRemoveUser = React.useCallback(
async (item) => {
@@ -50,7 +58,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
toast.error(t("Could not remove user"));
}
},
[history, userMemberships, user, document]
[t, history, userMemberships, user, document]
);
const handleUpdateUser = React.useCallback(
@@ -70,7 +78,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
toast.error(t("Could not update user"));
}
},
[userMemberships, document]
[t, userMemberships, document]
);
// Order newly added users first during the current editing session, on reload members are
@@ -87,10 +95,101 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
[document.members, invitedInSession]
);
const permissions = React.useMemo(
() =>
[
{
label: t("View only"),
value: DocumentPermission.Read,
},
{
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
},
{
label: t("Manage"),
value: DocumentPermission.Admin,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<>
{groupMemberships
.inDocument(document.id)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
).localeCompare(b.group.name)
)
.map((membership) => {
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
return (
<ListItem
key={membership.id}
image={
<GroupAvatar
group={membership.group}
backgroundColor={theme.modalBackground}
/>
}
title={membership.group.name}
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>
) : (
t("{{ count }} member", {
count: membership.group.memberCount,
})
)
}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission: DocumentPermission | typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
documentId: document.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
documentId: document.id,
groupId: membership.groupId,
permission,
});
}
}}
disabled={!can.manageUsers}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
);
})}
{members.map((item) => (
<MemberListItem
<DocumentMemberListItem
key={item.id}
user={item}
membership={item.getMembership(document)}
@@ -109,4 +208,9 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
);
}
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
`;
export default observer(DocumentMembersList);

View File

@@ -7,9 +7,9 @@ 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 { Avatar, AvatarSize } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import Time from "~/components/Time";
import { EmptySelectValue, Permission } from "~/types";
import { ListItem } from "../components/ListItem";
@@ -68,7 +68,6 @@ const DocumentMemberListItem = ({
if (!currentPermission) {
return null;
}
const disabled = !onUpdate && !onLeave;
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
return (
@@ -90,36 +89,35 @@ const DocumentMemberListItem = ({
</Trans>
) : user.isSuspended ? (
t("Suspended")
) : user.email ? (
user.email
) : user.isInvited ? (
t("Invited")
) : user.isViewer ? (
t("Viewer")
) : user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Editor")
t("Never signed in")
)
}
actions={
disabled ? null : (
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={
onLeave
? [
currentPermission,
{
label: `${t("Leave")}`,
value: EmptySelectValue,
},
]
: permissions
}
value={membership?.permission}
onChange={handleChange}
/>
</div>
)
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={
onLeave
? [
currentPermission,
{
label: `${t("Leave")}`,
value: EmptySelectValue,
},
]
: permissions
}
value={membership?.permission}
onChange={handleChange}
disabled={!onUpdate && !onLeave}
/>
</div>
}
/>
);

View File

@@ -18,7 +18,7 @@ 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 { AvatarSize } from "../../Avatar";
import CopyToClipboard from "../../CopyToClipboard";
import NudeButton from "../../NudeButton";
import { ResizingHeightContainer } from "../../ResizingHeightContainer";

View File

@@ -7,10 +7,10 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { DocumentPermission } from "@shared/types";
import Document from "~/models/Document";
import Group from "~/models/Group";
import Share from "~/models/Share";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import NudeButton from "~/components/NudeButton";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
@@ -53,7 +53,7 @@ function SharePopover({
const { t } = useTranslation();
const can = usePolicy(document);
const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships } = useStores();
const { users, userMemberships, groups, groupMemberships } = useStores();
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
@@ -129,9 +129,9 @@ function SharePopover({
name: t("Invite"),
section: UserSection,
perform: async () => {
const usersInvited = await Promise.all(
const invited = await Promise.all(
pendingIds.map(async (idOrEmail) => {
let user;
let user, group;
// convert email to user
if (isEmail(idOrEmail)) {
@@ -145,38 +145,77 @@ function SharePopover({
user = response[0];
} else {
user = users.get(idOrEmail);
group = groups.get(idOrEmail);
}
if (!user) {
return;
if (user) {
await userMemberships.create({
documentId: document.id,
userId: user.id,
permission,
});
return user;
}
await userMemberships.create({
documentId: document.id,
userId: user.id,
permission,
});
if (group) {
await groupMemberships.create({
documentId: document.id,
groupId: group.id,
permission,
});
return group;
}
return user;
return;
})
);
if (usersInvited.length === 1) {
const user = usersInvited[0] as User;
toast.message(
t("{{ userName }} was invited to the document", {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else {
toast.success(
t("{{ count }} people invited to the document", {
count: pendingIds.length,
})
);
const invitedUsers = invited.filter(
(item) => item instanceof User
) as User[];
const invitedGroups = invited.filter(
(item) => item instanceof Group
) as Group[];
if (invitedUsers.length > 0) {
// Special case for the common action of adding a single user.
if (invitedUsers.length === 1) {
const user = invitedUsers[0];
toast.message(
t("{{ userName }} was added to the document", {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else {
toast.message(
t("{{ count }} people added to the document", {
count: invitedUsers.length,
})
);
}
}
if (invitedGroups.length > 0) {
// Special case for the common action of adding a single group.
if (invitedGroups.length === 1) {
const group = invitedGroups[0];
toast.message(
t("{{ userName }} was added to the document", {
userName: group.name,
}),
{
icon: <GroupAvatar group={group} size={AvatarSize.Toast} />,
}
);
} else {
toast.message(
t("{{ count }} groups added to the document", {
count: invitedGroups.length,
})
);
}
}
setInvitedInSession((prev) => [...prev, ...pendingIds]);
@@ -185,14 +224,16 @@ function SharePopover({
},
}),
[
t,
pendingIds,
document.id,
groupMemberships,
groups,
hidePicker,
userMemberships,
document.id,
pendingIds,
permission,
users,
t,
team.defaultUserRole,
users,
]
);

View File

@@ -0,0 +1,47 @@
import times from "lodash/times";
import * as React from "react";
import { AvatarSize } from "~/components/Avatar";
import Fade from "~/components/Fade";
import PlaceholderText from "~/components/PlaceholderText";
import { ListItem } from "../components/ListItem";
type Props = {
count?: number;
};
/**
* Placeholder for a list item in the share popover.
*/
export function Placeholder({ count = 1 }: Props) {
return (
<Fade>
{times(count, (index) => (
<ListItem
key={index}
image={
<PlaceholderText
width={AvatarSize.Medium}
height={AvatarSize.Medium}
/>
}
title={
<PlaceholderText
maxWidth={50}
minWidth={30}
height={14}
style={{ marginTop: 4, marginBottom: 4 }}
/>
}
subtitle={
<PlaceholderText
maxWidth={75}
minWidth={50}
height={12}
style={{ marginBottom: 4 }}
/>
}
/>
))}
</Fade>
);
}

View File

@@ -1,11 +1,10 @@
import { isEmail } from "class-validator";
import concat from "lodash/concat";
import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import { CheckmarkIcon, CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import styled from "styled-components";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import Collection from "~/models/Collection";
@@ -13,8 +12,7 @@ import Document from "~/models/Document";
import Group from "~/models/Group";
import User from "~/models/User";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import Avatar from "~/components/Avatar";
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import { Avatar, GroupAvatar, AvatarSize, IAvatar } from "~/components/Avatar";
import Empty from "~/components/Empty";
import Placeholder from "~/components/List/Placeholder";
import Scrollable from "~/components/Scrollable";
@@ -42,8 +40,6 @@ type Props = {
addPendingId: (id: string) => void;
/** Callback to remove a user from the pending list. */
removePendingId: (id: string) => void;
/** Show group suggestions. */
showGroups?: boolean;
/** Handles escape from suggestions list */
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@@ -57,7 +53,6 @@ export const Suggestions = observer(
pendingIds,
addPendingId,
removePendingId,
showGroups,
onEscape,
}: Props,
ref: React.Ref<HTMLDivElement>
@@ -66,7 +61,6 @@ export const Suggestions = observer(
const { users, groups } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const { maxHeight } = useMaxHeight({
elementRef: containerRef,
@@ -76,10 +70,7 @@ export const Suggestions = observer(
const fetchUsersByQuery = useThrottledCallback(
(query: string) => {
void users.fetchPage({ query });
if (showGroups) {
void groups.fetchPage({ query });
}
void groups.fetchPage({ query });
},
250,
undefined,
@@ -113,11 +104,14 @@ export const Suggestions = observer(
filtered.push(getSuggestionForEmail(query));
}
if (collection?.id) {
return [...groups.notInCollection(collection.id, query), ...filtered];
}
return filtered;
return [
...(document
? groups.notInDocument(document.id, query)
: collection
? groups.notInCollection(collection.id, query)
: []),
...filtered,
];
}, [
getSuggestionForEmail,
users,
@@ -141,7 +135,7 @@ export const Suggestions = observer(
: users.get(id) ?? groups.get(id)
)
.filter(Boolean) as User[],
[users, getSuggestionForEmail, pendingIds]
[users, groups, getSuggestionForEmail, pendingIds]
);
React.useEffect(() => {
@@ -155,11 +149,7 @@ export const Suggestions = observer(
subtitle: t("{{ count }} member", {
count: suggestion.memberCount,
}),
image: (
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GroupIcon color={theme.background} size={16} />
</Squircle>
),
image: <GroupAvatar group={suggestion} />,
};
}
return {

View File

@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
@@ -13,7 +14,6 @@ import AccountMenu from "~/menus/AccountMenu";
import { fadeOnDesktopBackgrounded } from "~/styles";
import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import Avatar from "../Avatar";
import NotificationIcon from "../Notifications/NotificationIcon";
import NotificationsPopover from "../Notifications/NotificationsPopover";
import ResizeBorder from "./components/ResizeBorder";

View File

@@ -22,8 +22,8 @@ import CollectionMenu from "~/menus/CollectionMenu";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink, { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
type Props = {
collection: Collection;
@@ -48,7 +48,7 @@ const CollectionLink: React.FC<Props> = ({
const can = usePolicy(collection);
const { t } = useTranslation();
const history = useHistory();
const inStarredSection = useStarredContext();
const sidebarContext = useSidebarContext();
const editableTitleRef = React.useRef<RefHandle>(null);
const handleTitleChange = React.useCallback(
@@ -116,78 +116,69 @@ const CollectionLink: React.FC<Props> = ({
}),
});
const handleTitleEditing = React.useCallback((value: boolean) => {
setIsEditing(value);
}, []);
const handlePrefetch = React.useCallback(() => {
void collection.fetchDocuments();
}, [collection]);
const context = useActionContext({
activeCollectionId: collection.id,
inStarredSection,
sidebarContext,
});
return (
<>
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
to={{
pathname: collection.path,
state: { starred: inStarredSection },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.state?.starred === inStarredSection
}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
}
exact={false}
depth={0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onRename={() =>
editableTitleRef.current?.setIsEditing(true)
}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
</>
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={<CollectionIcon collection={collection} expanded={expanded} />}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
}
exact={false}
depth={0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onRename={() => editableTitleRef.current?.setIsEditing(true)}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
);
};

View File

@@ -42,7 +42,7 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
props.$root &&
css`
opacity: 0;
left: -16px;
left: -18px;
&:hover {
opacity: 1;

View File

@@ -27,9 +27,8 @@ import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { useSharedContext } from "./SharedContext";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink, { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
type Props = {
node: NavigationNode;
@@ -65,18 +64,20 @@ function InnerDocumentLink(
const { fetchChildDocuments } = documents;
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const inStarredSection = useStarredContext();
const inSharedSection = useSharedContext();
const sidebarContext = useSidebarContext();
React.useEffect(() => {
if (isActiveDocument && (hasChildDocuments || inSharedSection)) {
if (
isActiveDocument &&
(hasChildDocuments || sidebarContext !== "collections")
) {
void fetchChildDocuments(node.id);
}
}, [
fetchChildDocuments,
node.id,
hasChildDocuments,
inSharedSection,
sidebarContext,
isActiveDocument,
]);
@@ -338,7 +339,7 @@ function InnerDocumentLink(
pathname: node.url,
state: {
title: node.title,
starred: inStarredSection,
sidebarContext,
},
}}
icon={icon && <Icon value={icon} color={color} />}
@@ -352,16 +353,25 @@ function InnerDocumentLink(
ref={editableTitleRef}
/>
}
isActive={(match, location: Location<{ starred?: boolean }>) =>
((document && location.pathname.endsWith(document.urlId)) ||
!!match) &&
location.state?.starred === inStarredSection
}
isActive={(
match,
location: Location<{
sidebarContext?: SidebarContextType;
}>
) => {
if (sidebarContext !== location.state?.sidebarContext) {
return false;
}
return (
(document && location.pathname.endsWith(document.urlId)) ||
!!match
);
}}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
scrollIntoViewIfNeeded={!inStarredSection}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
menu={

View File

@@ -3,17 +3,18 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useLocationState } from "../hooks/useLocationState";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
import { DragObject } from "./SidebarLink";
type Props = {
@@ -23,23 +24,18 @@ type Props = {
belowCollection: Collection | void;
};
function useLocationStateStarred() {
const location = useLocation<{
starred?: boolean;
}>();
return location.state?.starred;
}
function DraggableCollectionLink({
collection,
activeDocument,
prefetchDocument,
belowCollection,
}: Props) {
const locationStateStarred = useLocationStateStarred();
const locationSidebarContext = useLocationState();
const sidebarContext = useSidebarContext();
const { ui, collections } = useStores();
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId && !locationStateStarred
collection.id === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
);
const can = usePolicy(collection);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
@@ -86,10 +82,18 @@ function DraggableCollectionLink({
// If the current collection is active and relevant to the sidebar section we
// are in then expand it automatically
React.useEffect(() => {
if (collection.id === ui.activeCollectionId && !locationStateStarred) {
if (
collection.id === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
}
}, [collection.id, ui.activeCollectionId, locationStateStarred]);
}, [
collection.id,
ui.activeCollectionId,
sidebarContext,
locationSidebarContext,
]);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();

View File

@@ -0,0 +1,48 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import Group from "~/models/Group";
import Folder from "./Folder";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
type Props = {
/** The group to render */
group: Group;
};
const GroupLink: React.FC<Props> = ({ group }) => {
const [expanded, setExpanded] = React.useState(false);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
return (
<Relative>
<SidebarLink
label={group.name}
icon={<GroupIcon />}
expanded={expanded}
onClick={handleDisclosureClick}
depth={0}
/>
<SidebarContext.Provider value={group.id}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
</SidebarContext.Provider>
</Relative>
);
};
export default observer(GroupLink);

View File

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

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
import GroupMembership from "~/models/GroupMembership";
import UserMembership from "~/models/UserMembership";
import DelayedMount from "~/components/DelayedMount";
import Flex from "~/components/Flex";
@@ -11,19 +12,22 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useStores from "~/hooks/useStores";
import DropCursor from "./DropCursor";
import GroupLink from "./GroupLink";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SharedContext from "./SharedContext";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useDropToReorderUserMembership } from "./useDragAndDrop";
function SharedWithMe() {
const { userMemberships } = useStores();
const { userMemberships, groupMemberships } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
const { loading, next, end, error, page } =
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
limit: Pagination.sidebarLimit,
@@ -31,7 +35,7 @@ function SharedWithMe() {
// Drop to reorder document
const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership(
() => fractionalIndex(null, user.memberships[0].index)
() => fractionalIndex(null, user.documentMemberships[0].index)
);
React.useEffect(() => {
@@ -40,14 +44,20 @@ function SharedWithMe() {
}
}, [error, t]);
if (!user.memberships.length) {
if (
!user.documentMemberships.length &&
!user.groupsWithDocumentMemberships.length
) {
return null;
}
return (
<SharedContext.Provider value={true}>
<SidebarContext.Provider value="shared">
<Flex column>
<Header id="shared" title={t("Shared with me")}>
{user.groupsWithDocumentMemberships.map((group) => (
<GroupLink key={group.id} group={group} />
))}
<Relative>
{reorderMonitor.isDragging && (
<DropCursor
@@ -56,13 +66,10 @@ function SharedWithMe() {
position="top"
/>
)}
{user.memberships
{user.documentMemberships
.slice(0, page * Pagination.sidebarLimit)
.map((membership) => (
<SharedWithMeLink
key={membership.id}
userMembership={membership}
/>
<SharedWithMeLink key={membership.id} membership={membership} />
))}
{!end && (
<SidebarLink
@@ -82,7 +89,7 @@ function SharedWithMe() {
</Relative>
</Header>
</Flex>
</SharedContext.Provider>
</SidebarContext.Provider>
);
}

View File

@@ -1,44 +1,61 @@
import fractionalIndex from "fractional-index";
import { Location } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { IconType, NotificationEventType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import GroupMembership from "~/models/GroupMembership";
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 { useLocationState } from "../hooks/useLocationState";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import {
useDragUserMembership,
useDragMembership,
useDropToReorderUserMembership,
} from "./useDragAndDrop";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
type Props = {
userMembership: UserMembership;
membership: UserMembership | GroupMembership;
depth?: number;
};
function SharedWithMeLink({ userMembership }: Props) {
function SharedWithMeLink({ membership, depth = 0 }: Props) {
const { ui, collections, documents } = useStores();
const { fetchChildDocuments } = documents;
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId } = userMembership;
const { documentId } = membership;
const isActiveDocument = documentId === ui.activeDocumentId;
const locationSidebarContext = useLocationState();
const sidebarContext = useSidebarContext();
const [expanded, setExpanded] = React.useState(
userMembership.documentId === ui.activeDocumentId
membership.documentId === ui.activeDocumentId &&
locationSidebarContext === sidebarContext
);
React.useEffect(() => {
if (userMembership.documentId === ui.activeDocumentId) {
if (
membership.documentId === ui.activeDocumentId &&
locationSidebarContext === sidebarContext
) {
setExpanded(true);
}
}, [userMembership.documentId, ui.activeDocumentId]);
}, [
membership.documentId,
ui.activeDocumentId,
sidebarContext,
locationSidebarContext,
]);
React.useEffect(() => {
if (documentId) {
@@ -47,10 +64,10 @@ function SharedWithMeLink({ userMembership }: Props) {
}, [documentId, documents]);
React.useEffect(() => {
if (isActiveDocument && userMembership.documentId) {
void fetchChildDocuments(userMembership.documentId);
if (isActiveDocument && membership.documentId) {
void fetchChildDocuments(membership.documentId);
}
}, [fetchChildDocuments, isActiveDocument, userMembership.documentId]);
}, [fetchChildDocuments, isActiveDocument, membership.documentId]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
@@ -61,12 +78,15 @@ function SharedWithMeLink({ userMembership }: Props) {
[]
);
const { icon } = useSidebarLabelAndIcon(userMembership);
const [{ isDragging }, draggableRef] = useDragUserMembership(userMembership);
const { icon } = useSidebarLabelAndIcon(membership);
const [{ isDragging }, draggableRef] = useDragMembership(membership);
const getIndex = () => {
const next = userMembership?.next();
return fractionalIndex(userMembership?.index || null, next?.index || null);
if (membership instanceof UserMembership) {
const next = membership?.next();
return fractionalIndex(membership?.index || null, next?.index || null);
}
return "";
};
const [reorderMonitor, dropToReorderRef] =
useDropToReorderUserMembership(getIndex);
@@ -95,19 +115,23 @@ function SharedWithMeLink({ userMembership }: Props) {
return (
<>
<Draggable
key={userMembership.id}
key={membership.id}
ref={draggableRef}
$isDragging={isDragging}
>
<SidebarLink
depth={0}
depth={depth}
to={{
pathname: document.path,
state: { starred: true },
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
unreadBadge={

View File

@@ -0,0 +1,9 @@
import * as React from "react";
export type SidebarContextType = "collections" | "starred" | string | undefined;
const SidebarContext = React.createContext<SidebarContextType>(undefined);
export const useSidebarContext = () => React.useContext(SidebarContext);
export default SidebarContext;

View File

@@ -11,8 +11,8 @@ import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import StarredContext from "./StarredContext";
import StarredLink from "./StarredLink";
import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
@@ -39,7 +39,7 @@ function Starred() {
}
return (
<StarredContext.Provider value={true}>
<SidebarContext.Provider value="starred">
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
@@ -80,7 +80,7 @@ function Starred() {
</Relative>
</Header>
</Flex>
</StarredContext.Provider>
</SidebarContext.Provider>
);
}

View File

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

View File

@@ -4,19 +4,23 @@ 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, { useTheme } from "styled-components";
import Star from "~/models/Star";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { useLocationState } from "../hooks/useLocationState";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarContext, {
SidebarContextType,
useSidebarContext,
} from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import {
useDragStar,
@@ -29,29 +33,32 @@ type Props = {
star: Star;
};
function useLocationStateStarred() {
const location = useLocation<{
starred?: boolean;
}>();
return location.state?.starred;
}
function StarredLink({ star }: Props) {
const theme = useTheme();
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collections.get(collectionId);
const locationStateStarred = useLocationStateStarred();
const locationSidebarContext = useLocationState();
const sidebarContext = useSidebarContext();
const [expanded, setExpanded] = useState(
star.collectionId === ui.activeCollectionId && !!locationStateStarred
star.collectionId === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
);
React.useEffect(() => {
if (star.collectionId === ui.activeCollectionId && locationStateStarred) {
if (
star.collectionId === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
}
}, [star.collectionId, ui.activeCollectionId, locationStateStarred]);
}, [
star.collectionId,
ui.activeCollectionId,
sidebarContext,
locationSidebarContext,
]);
useEffect(() => {
if (documentId) {
@@ -120,14 +127,15 @@ function StarredLink({ star }: Props) {
depth={0}
to={{
pathname: document.url,
state: { starred: true },
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
icon={icon}
isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.state?.starred === true
}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
@@ -144,22 +152,24 @@ function StarredLink({ star }: Props) {
}
/>
</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>
{cursor}
</Relative>
<SidebarContext.Provider value={document.id}>
<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>
{cursor}
</Relative>
</SidebarContext.Provider>
</>
);
}
@@ -176,13 +186,15 @@ function StarredLink({ star }: Props) {
isDraggingAnyCollection={reorderStarMonitor.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
<SidebarContext.Provider value={collection.id}>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
</>
);
}

View File

@@ -4,6 +4,7 @@ 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 GroupMembership from "~/models/GroupMembership";
import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -90,11 +91,16 @@ export function useDropToReorderStar(getIndex?: () => string) {
});
}
export function useDragUserMembership(
userMembership: UserMembership
/**
* Hook for shared logic that allows dragging user memberships to reorder
*
* @param membership The UserMembership or GroupMembership model to drag.
*/
export function useDragMembership(
membership: UserMembership | GroupMembership
): [{ isDragging: boolean }, ConnectDragSource] {
const id = userMembership.id;
const { label: title, icon } = useSidebarLabelAndIcon(userMembership);
const id = membership.id;
const { label: title, icon } = useSidebarLabelAndIcon(membership);
const [{ isDragging }, draggableRef, preview] = useDrag({
type: "userMembership",
@@ -106,7 +112,7 @@ export function useDragUserMembership(
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: () => true,
canDrag: () => membership instanceof UserMembership,
});
React.useEffect(() => {
@@ -130,7 +136,9 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
drop: async (item: DragObject) => {
const userMembership = userMemberships.get(item.id);
void userMembership?.save({
index: getIndex?.() ?? fractionalIndex(null, user.memberships[0].index),
index:
getIndex?.() ??
fractionalIndex(null, user.documentMemberships[0].index),
});
},
collect: (monitor) => ({

View File

@@ -0,0 +1,12 @@
import { useLocation } from "react-router-dom";
import { SidebarContextType } from "../components/SidebarContext";
/**
* Hook to retrieve the sidebar context from the current location state.
*/
export function useLocationState() {
const location = useLocation<{
sidebarContext?: SidebarContextType;
}>();
return location.state?.sidebarContext;
}

View File

@@ -1,6 +1,6 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Avatar from "./Avatar";
import { Avatar } from "./Avatar";
const TeamLogo = styled(Avatar)`
border-radius: 4px;

View File

@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { AvatarSize } from "~/components/Avatar";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect, { Option } from "~/components/InputSelect";
import TeamLogo from "~/components/TeamLogo";

View File

@@ -263,42 +263,77 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("documents.add_user", async (event: UserMembership) => {
userMemberships.add(event);
this.socket.on(
"documents.add_user",
async (event: PartialWithId<UserMembership>) => {
userMemberships.add(event);
// Any existing child policies are now invalid
if (event.userId === currentUserId) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
policies.remove(childDocument.id);
});
// Any existing child policies are now invalid
if (event.userId === currentUserId) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
policies.remove(childDocument.id);
});
}
}
await documents.fetch(event.documentId!, {
force: event.userId === currentUserId,
});
}
);
this.socket.on(
"documents.remove_user",
(event: PartialWithId<UserMembership>) => {
userMemberships.remove(event.id);
// Any existing child policies are now invalid
if (event.userId === currentUserId) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
policies.remove(childDocument.id);
});
}
}
const policy = policies.get(event.documentId!);
if (policy && policy.abilities.read === false) {
documents.remove(event.documentId!);
}
}
);
await documents.fetch(event.documentId!, {
force: event.userId === currentUserId,
});
});
this.socket.on(
"documents.add_group",
(event: PartialWithId<GroupMembership>) => {
groupMemberships.add(event);
this.socket.on("documents.remove_user", (event: UserMembership) => {
userMemberships.remove(event.id);
const group = groups.get(event.groupId!);
// Any existing child policies are now invalid
if (event.userId === currentUserId) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
policies.remove(childDocument.id);
});
// Any existing child policies are now invalid
if (
currentUserId &&
group?.users.map((u) => u.id).includes(currentUserId)
) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
policies.remove(childDocument.id);
});
}
}
}
);
const policy = policies.get(event.documentId!);
if (policy && policy.abilities.read === false) {
documents.remove(event.documentId!);
this.socket.on(
"documents.remove_group",
(event: PartialWithId<GroupMembership>) => {
groupMemberships.remove(event.id);
}
});
);
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
comments.add(event);
@@ -328,8 +363,11 @@ class WebsocketProvider extends React.Component<Props> {
groupUsers.add(event);
});
this.socket.on("groups.remove_user", (event: GroupUser) => {
groupUsers.removeAll({ groupId: event.groupId, userId: event.userId });
this.socket.on("groups.remove_user", (event: PartialWithId<GroupUser>) => {
groupUsers.removeAll({
groupId: event.groupId,
userId: event.userId,
});
});
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {

View File

@@ -7,8 +7,7 @@ import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";

View File

@@ -27,13 +27,13 @@ const useTemplatesActions = () => {
<NewDocumentIcon />
),
keywords: "create",
perform: ({ activeCollectionId, inStarredSection }) =>
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(item.collectionId ?? activeCollectionId, {
templateId: item.id,
}),
{
starred: inStarredSection,
sidebarContext,
}
),
})

View File

@@ -12,6 +12,7 @@ import type CollectionsStore from "~/stores/CollectionsStore";
import Document from "~/models/Document";
import ParanoidModel from "~/models/base/ParanoidModel";
import { client } from "~/utils/ApiClient";
import User from "./User";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";
@@ -176,6 +177,19 @@ export default class Collection extends ParanoidModel {
return this.url;
}
/**
* Returns users that have been individually given access to the collection.
*
* @returns A list of users that have been given access to the collection.
*/
@computed
get members(): User[] {
return this.store.rootStore.memberships.orderedData
.filter((m) => m.collectionId === this.id)
.map((m) => m.user)
.filter(Boolean);
}
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;

View File

@@ -175,6 +175,9 @@ export default class Document extends ParanoidModel {
@observable
parentDocumentId: string | undefined;
@Relation(() => Document)
parentDocument?: Document;
@observable
collaboratorIds: string[];
@@ -376,9 +379,26 @@ export default class Document extends ParanoidModel {
return floor((this.tasks.completed / this.tasks.total) * 100);
}
/**
* Returns the path to the document, using the collection structure if available.
* otherwise if we're viewing a shared document we can iterate up the parentDocument tree.
*
* @returns path to the document
*/
@computed
get pathTo() {
return this.collection?.pathToDocument(this.id) ?? [];
if (this.collection?.documents) {
return this.collection.pathToDocument(this.id);
}
// find root parent document we have access to
const path: Document[] = [this];
while (path[0]?.parentDocument) {
path.unshift(path[0].parentDocument);
}
return path.map((item) => item.asNavigationNode);
}
@computed
@@ -582,6 +602,8 @@ export default class Document extends ParanoidModel {
return {
id: this.id,
title: this.title,
color: this.color ?? undefined,
icon: this.icon ?? undefined,
children: this.childDocuments.map((doc) => doc.asNavigationNode),
url: this.url,
isDraft: this.isDraft,

View File

@@ -1,4 +1,5 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import GroupMembership from "./GroupMembership";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -15,6 +16,46 @@ class Group extends Model {
@observable
memberCount: number;
/**
* Returns the users that are members of this group.
*/
@computed
get users() {
const { users } = this.store.rootStore;
return users.inGroup(this.id);
}
/**
* Returns the direct memberships that this group has to documents. Documents that the current
* user already has access to through a collection and trashed documents are not included.
*
* @returns A list of group memberships
*/
@computed
get documentMemberships(): GroupMembership[] {
const { groupMemberships, groupUsers, documents, policies, auth } =
this.store.rootStore;
return groupMemberships.orderedData
.filter((groupMembership) =>
groupUsers.orderedData.some(
(groupUser) =>
groupUser.groupId === groupMembership.groupId &&
groupUser.userId === auth.user?.id
)
)
.filter(
(m) => m.groupId === this.id && m.sourceId === null && m.documentId
)
.filter((m) => {
const document = documents.get(m.documentId!);
const policy = document?.collectionId
? policies.get(document.collectionId)
: undefined;
return !policy?.abilities?.readDocument && !document?.isDeleted;
});
}
}
export default Group;

View File

@@ -34,6 +34,13 @@ class GroupMembership extends Model {
@Relation(() => Collection, { onDelete: "cascade" })
collection: Collection | undefined;
/** The source ID points to the root membership from which this inherits */
sourceId?: string;
/** The source points to the root membership from which this inherits */
@Relation(() => GroupMembership, { onDelete: "cascade" })
source?: GroupMembership;
/** The permission level granted to the group. */
@observable
permission: CollectionPermission | DocumentPermission;

View File

@@ -13,6 +13,7 @@ import {
import type { NotificationSettings } from "@shared/types";
import { client } from "~/utils/ApiClient";
import Document from "./Document";
import Group from "./Group";
import UserMembership from "./UserMembership";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
@@ -127,21 +128,40 @@ class User extends ParanoidModel {
);
}
/**
* Returns the direct memberships that this user has to documents. Documents that the
* user already has access to through a collection and trashed documents are not included.
*
* @returns A list of user memberships
*/
@computed
get memberships(): UserMembership[] {
return this.store.rootStore.userMemberships.orderedData
get documentMemberships(): UserMembership[] {
const { userMemberships, documents, policies } = this.store.rootStore;
return userMemberships.orderedData
.filter(
(m) => m.userId === this.id && m.sourceId === null && m.documentId
)
.filter((m) => {
const document = this.store.rootStore.documents.get(m.documentId!);
const document = documents.get(m.documentId!);
const policy = document?.collectionId
? this.store.rootStore.policies.get(document.collectionId)
? policies.get(document.collectionId)
: undefined;
return !policy?.abilities?.readDocument && !document?.isDeleted;
});
}
@computed
get groupsWithDocumentMemberships() {
const { groups, groupUsers } = this.store.rootStore;
return groupUsers.orderedData
.filter((groupUser) => groupUser.userId === this.id)
.map((groupUser) => groups.get(groupUser.groupId))
.filter(Boolean)
.filter((group) => group && group.documentMemberships.length > 0)
.sort((a, b) => a!.name.localeCompare(b!.name)) as Group[];
}
/**
* Returns the current preference for the given notification event type taking
* into account the default system value.

View File

@@ -4,8 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Collection from "~/models/Collection";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";

View File

@@ -12,7 +12,7 @@ import { ProsemirrorData } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation, CommentValidation } from "@shared/validations";
import Comment from "~/models/Comment";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";

View File

@@ -10,7 +10,7 @@ import { s } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";

View File

@@ -13,7 +13,7 @@ import { ProsemirrorData } from "@shared/types";
import { dateToRelative } from "@shared/utils/date";
import { Minute } from "@shared/utils/time";
import Comment from "~/models/Comment";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import Flex from "~/components/Flex";
import Text from "~/components/Text";

View File

@@ -7,7 +7,7 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import DocumentViews from "~/components/DocumentViews";
import Flex from "~/components/Flex";

View File

@@ -6,8 +6,7 @@ import { toast } from "sonner";
import Group from "~/models/Group";
import User from "~/models/User";
import Invite from "~/scenes/Invite";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import ButtonLink from "~/components/ButtonLink";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";

View File

@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import Flex from "~/components/Flex";

View File

@@ -3,8 +3,7 @@ import { UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import FilterOptions from "~/components/FilterOptions";
import useStores from "~/hooks/useStores";

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import Avatar, { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import { Avatar, AvatarSize, IAvatar } from "~/components/Avatar";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Flex from "~/components/Flex";
import TableFromParams from "~/components/TableFromParams";

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import Avatar from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import TableFromParams from "~/components/TableFromParams";
import Time from "~/components/Time";

View File

@@ -207,6 +207,8 @@ export default class AuthStore extends Store<Team> {
this.addPolicies(res.policies);
this.add(data.team);
this.rootStore.users.add(data.user);
data.groups.map(this.rootStore.groups.add);
data.groupUsers.map(this.rootStore.groupUsers.add);
this.currentUserId = data.user.id;
this.currentTeamId = data.team.id;

View File

@@ -7,7 +7,6 @@ import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
import type {
DateFilter,
JSONObject,
NavigationNode,
PublicTeam,
StatusFilter,
@@ -23,7 +22,6 @@ import type {
FetchOptions,
PaginationParams,
PartialWithId,
Properties,
SearchResult,
} from "~/types";
import { client } from "~/utils/ApiClient";
@@ -742,34 +740,6 @@ export default class DocumentsStore extends Store<Document> {
}
};
@action
async update(
params: Properties<Document>,
options?: JSONObject
): Promise<Document> {
this.isSaving = true;
try {
const res = await client.post(`/${this.apiEndpoint}.update`, {
...params,
...options,
});
invariant(res?.data, "Data should be available");
const collection = this.getCollectionForDocument(res.data);
await collection?.fetchDocuments({ force: true });
return runInAction("Document#update", () => {
const document = this.add(res.data);
this.addPolicies(res.policies);
return document;
});
} finally {
this.isSaving = false;
}
}
@action
unpublish = async (document: Document) => {
const res = await client.post("/documents.unpublish", {

View File

@@ -15,18 +15,36 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
}
@action
fetchPage = async (
params: PaginationParams | undefined
): Promise<GroupMembership[]> => {
fetchPage = async ({
collectionId,
documentId,
...params
}:
| PaginationParams & {
documentId?: string;
collectionId?: string;
groupId?: string;
}): Promise<GroupMembership[]> => {
this.isFetching = true;
try {
const res = await client.post(`/collections.group_memberships`, params);
const res = collectionId
? await client.post(`/collections.group_memberships`, {
id: collectionId,
...params,
})
: documentId
? await client.post(`/documents.group_memberships`, {
id: documentId,
...params,
})
: await client.post(`/groupMemberships.list`, params);
invariant(res?.data, "Data not available");
let response: GroupMembership[] = [];
runInAction(`GroupMembershipsStore#fetchPage`, () => {
res.data.groups.forEach(this.rootStore.groups.add);
res.data.groups?.forEach(this.rootStore.groups.add);
res.data.documents?.forEach(this.rootStore.documents.add);
response = res.data.groupMemberships.map(this.add);
this.isLoaded = true;
});

View File

@@ -41,6 +41,13 @@ export default class GroupsStore extends Store<Group> {
}
};
/**
* Returns groups that are in the given collection, optionally filtered by a query.
*
* @param collectionId
* @param query
* @returns A list of groups that are in the given collection.
*/
inCollection = (collectionId: string, query?: string) => {
const memberships = filter(
this.rootStore.groupMemberships.orderedData,
@@ -50,12 +57,38 @@ export default class GroupsStore extends Store<Group> {
const groups = filter(this.orderedData, (group) =>
groupIds.includes(group.id)
);
if (!query) {
return groups;
}
return queriedGroups(groups, query);
return query ? queriedGroups(groups, query) : groups;
};
/**
* Returns groups that are not in the given document, optionally filtered by a query.
*
* @param documentId
* @param query
* @returns A list of groups that are not in the given document.
*/
notInDocument = (documentId: string, query = "") => {
const memberships = filter(
this.rootStore.groupMemberships.orderedData,
(member) => member.documentId === documentId
);
const groupIds = memberships.map((member) => member.groupId);
const groups = filter(
this.orderedData,
(group) => !groupIds.includes(group.id)
);
return query ? queriedGroups(groups, query) : groups;
};
/**
* Returns groups that are not in the given collection, optionally filtered by a query.
*
* @param collectionId
* @param query
* @returns A list of groups that are not in the given collection.
*/
notInCollection = (collectionId: string, query = "") => {
const memberships = filter(
this.rootStore.groupMemberships.orderedData,
@@ -66,10 +99,8 @@ export default class GroupsStore extends Store<Group> {
this.orderedData,
(group) => !groupIds.includes(group.id)
);
if (!query) {
return groups;
}
return queriedGroups(groups, query);
return query ? queriedGroups(groups, query) : groups;
};
}

View File

@@ -12,7 +12,7 @@ import DocumentsStore from "./DocumentsStore";
import EventsStore from "./EventsStore";
import FileOperationsStore from "./FileOperationsStore";
import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupUserMembershipsStore from "./GroupUserMembershipsStore";
import GroupUsersStore from "./GroupUsersStore";
import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
@@ -42,7 +42,7 @@ export default class RootStore {
documents: DocumentsStore;
events: EventsStore;
groups: GroupsStore;
groupUsers: GroupUserMembershipsStore;
groupUsers: GroupUsersStore;
integrations: IntegrationsStore;
memberships: MembershipsStore;
notifications: NotificationsStore;
@@ -71,7 +71,7 @@ export default class RootStore {
this.registerStore(DocumentsStore);
this.registerStore(EventsStore);
this.registerStore(GroupsStore);
this.registerStore(GroupUserMembershipsStore);
this.registerStore(GroupUsersStore);
this.registerStore(IntegrationsStore);
this.registerStore(MembershipsStore);
this.registerStore(NotificationsStore);

View File

@@ -138,6 +138,13 @@ export default class UsersStore extends Store<User> {
}
};
/**
* Returns users that are not in the given document, optionally filtered by a query.
*
* @param documentId
* @param query
* @returns A list of users that are not in the given document.
*/
notInDocument = (documentId: string, query = "") => {
const document = this.rootStore.documents.get(documentId);
const teamMembers = this.activeOrInvited;
@@ -150,6 +157,13 @@ export default class UsersStore extends Store<User> {
return queriedUsers(users, query);
};
/**
* Returns users that are not in the given collection, optionally filtered by a query.
*
* @param collectionId
* @param query
* @returns A list of users that are not in the given collection.
*/
notInCollection = (collectionId: string, query = "") => {
const groupUsers = filter(
this.rootStore.memberships.orderedData,

View File

@@ -7,6 +7,7 @@ import {
DocumentPermission,
} from "@shared/types";
import RootStore from "~/stores/RootStore";
import { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
import Document from "./models/Document";
import FileOperation from "./models/FileOperation";
import Pin from "./models/Pin";
@@ -82,7 +83,7 @@ export type ActionContext = {
isContextMenu: boolean;
isCommandBar: boolean;
isButton: boolean;
inStarredSection?: boolean;
sidebarContext?: SidebarContextType;
activeCollectionId?: string | undefined;
activeDocumentId: string | undefined;
currentUserId: string | undefined;

View File

@@ -4,7 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar/Avatar";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";

View File

@@ -42,7 +42,6 @@ import {
presentGroupMembership,
presentComment,
} from "@server/presenters";
import presentDocumentGroupMembership from "@server/presenters/documentGroupMembership";
import BaseTask from "@server/queues/tasks/BaseTask";
import {
CollectionEvent,
@@ -611,7 +610,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
subscription,
payload: {
id: event.modelId,
model: model && presentDocumentGroupMembership(model),
model: model && presentGroupMembership(model),
document,
group: model && (await presentGroup(model.group)),
},

View File

@@ -172,14 +172,8 @@ 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.scope([
"withDrafts",
{ method: ["withMembership", user.id] },
]).findOne({
where: {
id: document.id,
publishedAt: document.publishedAt,
},
return Document.findByPk(document.id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});

View File

@@ -59,7 +59,7 @@ export default async function documentDuplicator({
...sharedProperties,
});
duplicated.collection = collection;
duplicated.collection = collection ?? null;
newDocuments.push(duplicated);
async function duplicateChildDocuments(
@@ -95,7 +95,7 @@ export default async function documentDuplicator({
...sharedProperties,
});
duplicatedChildDocument.collection = collection;
duplicatedChildDocument.collection = collection ?? null;
newDocuments.push(duplicatedChildDocument);
await duplicateChildDocuments(childDocument, duplicatedChildDocument);
}

View File

@@ -8,6 +8,7 @@ import {
Pin,
Event,
UserMembership,
GroupMembership,
} from "@server/models";
import pinDestroyer from "./pinDestroyer";
@@ -226,9 +227,14 @@ 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([
// If there are any sourced memberships for this document, we need to go to the source
// memberships and recalculate the membership for the user or group.
const [
userMemberships,
parentDocumentUserMemberships,
groupMemberships,
parentDocumentGroupMemberships,
] = await Promise.all([
UserMembership.findRootMembershipsForDocument(document.id, undefined, {
transaction,
}),
@@ -239,10 +245,25 @@ async function documentMover({
{ transaction }
)
: [],
GroupMembership.findRootMembershipsForDocument(document.id, undefined, {
transaction,
}),
parentDocumentId
? GroupMembership.findRootMembershipsForDocument(
parentDocumentId,
undefined,
{ transaction }
)
: [],
]);
await recalculatePermissions(documentPermissions, transaction);
await recalculatePermissions(parentDocumentPermissions, transaction);
await recalculateUserMemberships(userMemberships, transaction);
await recalculateUserMemberships(parentDocumentUserMemberships, transaction);
await recalculateGroupMemberships(groupMemberships, transaction);
await recalculateGroupMemberships(
parentDocumentGroupMemberships,
transaction
);
await Event.create(
{
@@ -267,12 +288,21 @@ async function documentMover({
return result;
}
async function recalculatePermissions(
permissions: UserMembership[],
async function recalculateUserMemberships(
memberships: UserMembership[],
transaction?: Transaction
) {
for (const permission of permissions) {
await UserMembership.createSourcedMemberships(permission, { transaction });
for (const membership of memberships) {
await UserMembership.createSourcedMemberships(membership, { transaction });
}
}
async function recalculateGroupMemberships(
memberships: GroupMembership[],
transaction?: Transaction
) {
for (const membership of memberships) {
await GroupMembership.createSourcedMemberships(membership, { transaction });
}
}

View File

@@ -72,7 +72,7 @@ import NotContainsUrl from "./validators/NotContainsUrl";
separate: true,
// include for groups that are members of this collection,
// of which userId is a member of, resulting in:
// CollectionGroup [inner join] Group [inner join] GroupUser [where] userId
// GroupMembership [inner join] Group [inner join] GroupUser [where] userId
include: [
{
model: Group,
@@ -99,47 +99,52 @@ import NotContainsUrl from "./validators/NotContainsUrl";
},
],
}),
withMembership: (userId: string) => ({
include: [
{
model: UserMembership,
as: "memberships",
where: {
userId,
},
required: false,
},
{
model: GroupMembership,
as: "groupMemberships",
required: false,
// use of "separate" property: sequelize breaks when there are
// nested "includes" with alternating values for "required"
// see https://github.com/sequelize/sequelize/issues/9869
separate: true,
// include for groups that are members of this collection,
// of which userId is a member of, resulting in:
// CollectionGroup [inner join] Group [inner join] GroupUser [where] userId
include: [
{
model: Group,
as: "group",
required: true,
include: [
{
model: GroupUser,
as: "groupUsers",
required: true,
where: {
userId,
},
},
],
withMembership: (userId: string) => {
if (!userId) {
return {};
}
return {
include: [
{
association: "memberships",
where: {
userId,
},
],
},
],
}),
required: false,
},
{
model: GroupMembership,
as: "groupMemberships",
required: false,
// use of "separate" property: sequelize breaks when there are
// nested "includes" with alternating values for "required"
// see https://github.com/sequelize/sequelize/issues/9869
separate: true,
// include for groups that are members of this collection,
// of which userId is a member of, resulting in:
// CollectionGroup [inner join] Group [inner join] GroupUser [where] userId
include: [
{
model: Group,
as: "group",
required: true,
include: [
{
model: GroupUser,
as: "groupUsers",
required: true,
where: {
userId,
},
},
],
},
],
},
],
};
},
}))
@Table({ tableName: "collections", modelName: "collection" })
@Fix
@@ -353,7 +358,7 @@ class Collection extends ParanoidModel<
/**
* Returns an array of unique userIds that are members of a collection,
* either via group or direct membership
* either via group or direct membership.
*
* @param collectionId
* @returns userIds
@@ -362,13 +367,12 @@ class Collection extends ParanoidModel<
const collection = await this.scope("withAllMemberships").findByPk(
collectionId
);
if (!collection) {
return [];
}
const groupMemberships = collection.groupMemberships
.map((cgm) => cgm.group.groupUsers)
.map((gm) => gm.group.groupUsers)
.flat();
const membershipUserIds = [
...groupMemberships,

View File

@@ -55,6 +55,9 @@ import { generateUrlId } from "@server/utils/url";
import Backlink from "./Backlink";
import Collection from "./Collection";
import FileOperation from "./FileOperation";
import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Revision from "./Revision";
import Star from "./Star";
import Team from "./Team";
@@ -147,13 +150,11 @@ type AdditionalFindOptions = {
withDrafts: {
include: [
{
model: User,
as: "createdBy",
association: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
association: "updatedBy",
paranoid: false,
},
],
@@ -180,6 +181,7 @@ type AdditionalFindOptions = {
if (!userId) {
return {};
}
return {
include: [
{
@@ -189,6 +191,34 @@ type AdditionalFindOptions = {
},
required: false,
},
{
association: "groupMemberships",
required: false,
// use of "separate" property: sequelize breaks when there are
// nested "includes" with alternating values for "required"
// see https://github.com/sequelize/sequelize/issues/9869
separate: true,
// include for groups that are members of this document,
// of which userId is a member of, resulting in:
// GroupMembership [inner join] Group [inner join] GroupUser [where] userId
include: [
{
model: Group,
as: "group",
required: true,
include: [
{
model: GroupUser,
as: "groupUsers",
required: true,
where: {
userId,
},
},
],
},
],
},
],
};
},
@@ -196,6 +226,33 @@ type AdditionalFindOptions = {
include: [
{
association: "memberships",
required: false,
},
{
model: GroupMembership,
as: "groupMemberships",
required: false,
// use of "separate" property: sequelize breaks when there are
// nested "includes" with alternating values for "required"
// see https://github.com/sequelize/sequelize/issues/9869
separate: true,
// include for groups that are members of this collection,
// of which userId is a member of, resulting in:
// CollectionGroup [inner join] Group [inner join] GroupUser [where] userId
include: [
{
model: Group,
as: "group",
required: true,
include: [
{
model: GroupUser,
as: "groupUsers",
required: true,
},
],
},
],
},
],
},
@@ -534,7 +591,7 @@ class Document extends ParanoidModel<
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null | undefined;
collection: Collection | null;
@BelongsToMany(() => User, () => UserMembership)
users: User[];
@@ -546,6 +603,9 @@ class Document extends ParanoidModel<
@HasMany(() => UserMembership)
memberships: UserMembership[];
@HasMany(() => GroupMembership, "documentId")
groupMemberships: GroupMembership[];
@HasMany(() => Revision)
revisions: Revision[];
@@ -559,22 +619,28 @@ class Document extends ParanoidModel<
views: View[];
/**
* Returns an array of unique userIds that are members of a document via direct membership
* Returns an array of unique userIds that are members of a document
* either via group or direct membership.
*
* @param documentId
* @returns userIds
*/
static async membershipUserIds(documentId: string) {
const document = await this.scope("withAllMemberships").findOne({
where: {
id: documentId,
},
where: { id: documentId },
});
if (!document) {
return [];
}
return document.memberships.map((membership) => membership.userId);
const groupMemberships = document.groupMemberships
.map((gm) => gm.group.groupUsers)
.flat();
const membershipUserIds = [
...groupMemberships,
...document.memberships,
].map((membership) => membership.userId);
return uniq(membershipUserIds);
}
static defaultScopeWithUser(userId: string) {
@@ -841,31 +907,23 @@ 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,
}
)
)
);
// Copy the group and user memberships from the parent document, if any
if (this.parentDocumentId) {
await GroupMembership.copy(
{
documentId: this.parentDocumentId,
},
this,
{ transaction }
);
await UserMembership.copy(
{
documentId: this.parentDocumentId,
},
this,
{ transaction }
);
}
this.lastModifiedById = user.id;
this.updatedBy = user;

View File

@@ -4,6 +4,8 @@ import {
Op,
type SaveOptions,
type FindOptions,
type DestroyOptions,
type WhereOptions,
} from "sequelize";
import {
BelongsTo,
@@ -16,6 +18,7 @@ import {
Scopes,
AfterCreate,
AfterUpdate,
AfterDestroy,
} from "sequelize-typescript";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import Collection from "./Collection";
@@ -67,6 +70,7 @@ class GroupMembership extends ParanoidModel<
InferAttributes<GroupMembership>,
Partial<InferCreationAttributes<GroupMembership>>
> {
/** The permission granted to the group. */
@Default(CollectionPermission.ReadWrite)
@IsIn([Object.values(CollectionPermission)])
@Column(DataType.STRING)
@@ -74,51 +78,86 @@ class GroupMembership extends ParanoidModel<
// associations
/** The collection that this permission grants the group access to. */
/** The collection that this membership grants the group access to. */
@BelongsTo(() => Collection, "collectionId")
collection?: Collection | null;
/** The collection ID that this permission grants the group access to. */
/** The collection ID that this membership grants the group access to. */
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId?: string | null;
/** The document that this permission grants the group access to. */
/** The document that this membership grants the group access to. */
@BelongsTo(() => Document, "documentId")
document?: Document | null;
/** The document ID that this permission grants the group access to. */
/** The document ID that this membership grants the group 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 */
/** If this represents the membership on a child then this points to the membership on the root */
@BelongsTo(() => GroupMembership, "sourceId")
source?: GroupMembership | null;
/** If this represents the permission on a child then this points to the permission on the root */
/** If this represents the membership on a child then this points to the membership on the root */
@ForeignKey(() => GroupMembership)
@Column(DataType.UUID)
sourceId?: string | null;
/** The group that this permission is granted to. */
/** The group that this membership is granted to. */
@BelongsTo(() => Group, "groupId")
group: Group;
/** The group ID that this permission is granted to. */
/** The group ID that this membership is granted to. */
@ForeignKey(() => Group)
@Column(DataType.UUID)
groupId: string;
/** The user that created this permission. */
/** The user that created this membership. */
@BelongsTo(() => User, "createdById")
createdBy: User;
/** The user ID that created this permission. */
/** The user ID that created this membership. */
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
// static methods
/**
* Copy group memberships from one document to another.
*
* @param where The where clause to find the group memberships to copy.
* @param document The document to copy the group memberships to.
* @param options Additional options to pass to the query.
*/
public static async copy(
where: WhereOptions<GroupMembership>,
document: Document,
options: SaveOptions
) {
const { transaction } = options;
const groupMemberships = await this.findAll({
where,
transaction,
});
await Promise.all(
groupMemberships.map((membership) =>
this.create(
{
documentId: document.id,
groupId: membership.groupId,
sourceId: membership.sourceId ?? membership.id,
permission: membership.permission,
createdById: membership.createdById,
},
{ transaction }
)
)
);
}
/**
* Find the root membership for a document and (optionally) group.
*
@@ -127,7 +166,7 @@ class GroupMembership extends ParanoidModel<
* @param options Additional options to pass to the query.
* @returns A promise that resolves to the root memberships for the document and group, or null.
*/
static async findRootMembershipsForDocument(
public static async findRootMembershipsForDocument(
documentId: string,
groupId?: string,
options?: FindOptions<GroupMembership>
@@ -150,6 +189,20 @@ class GroupMembership extends ParanoidModel<
return rootMemberships.filter(Boolean) as GroupMembership[];
}
// hooks
@AfterCreate
static async createSourcedMemberships(
model: GroupMembership,
options: SaveOptions<GroupMembership>
) {
if (model.sourceId || !model.documentId) {
return;
}
return this.recreateSourcedMemberships(model, options);
}
@AfterUpdate
static async updateSourcedMemberships(
model: GroupMembership,
@@ -168,6 +221,7 @@ class GroupMembership extends ParanoidModel<
},
{
where: {
groupId: model.groupId,
sourceId: model.id,
},
transaction,
@@ -176,16 +230,23 @@ class GroupMembership extends ParanoidModel<
}
}
@AfterCreate
static async createSourcedMemberships(
@AfterDestroy
static async destroySourcedMemberships(
model: GroupMembership,
options: SaveOptions<GroupMembership>
options: DestroyOptions<GroupMembership>
) {
if (model.sourceId || !model.documentId) {
return;
}
return this.recreateSourcedMemberships(model, options);
const { transaction } = options;
await this.destroy({
where: {
groupId: model.groupId,
sourceId: model.id,
},
transaction,
});
}
/**
@@ -202,6 +263,7 @@ class GroupMembership extends ParanoidModel<
await this.destroy({
where: {
groupId: model.groupId,
sourceId: model.id,
},
transaction,

View File

@@ -48,7 +48,12 @@ import Length from "./validators/Length";
withCollectionPermissions: (userId: string) => ({
include: [
{
model: Document.scope("withDrafts"),
model: Document.scope([
"withDrafts",
{
method: ["withMembership", userId],
},
]),
paranoid: true,
as: "document",
include: [
@@ -59,13 +64,6 @@ import Length from "./validators/Length";
}),
as: "collection",
},
{
association: "memberships",
where: {
userId,
},
required: false,
},
],
},
{

View File

@@ -5,6 +5,7 @@ import {
type SaveOptions,
type FindOptions,
} from "sequelize";
import { WhereOptions } from "sequelize";
import {
Column,
ForeignKey,
@@ -67,6 +68,7 @@ class UserMembership extends IdModel<
InferAttributes<UserMembership>,
Partial<InferCreationAttributes<UserMembership>>
> {
/** The permission granted to the user. */
@Default(CollectionPermission.ReadWrite)
@IsIn([Object.values(CollectionPermission)])
@Column(DataType.STRING)
@@ -82,51 +84,86 @@ class UserMembership extends IdModel<
// associations
/** The collection that this permission grants the user access to. */
/** The collection that this membership grants the user access to. */
@BelongsTo(() => Collection, "collectionId")
collection?: Collection | null;
/** The collection ID that this permission grants the user access to. */
/** The collection ID that this membership grants the user access to. */
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId?: string | null;
/** The document that this permission grants the user access to. */
/** The document that this membership grants the user access to. */
@BelongsTo(() => Document, "documentId")
document?: Document | null;
/** The document ID that this permission grants the user access to. */
/** The document ID that this membership 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 */
/** If this represents the membership on a child then this points to the membership 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 */
/** If this represents the membership on a child then this points to the membership on the root */
@ForeignKey(() => UserMembership)
@Column(DataType.UUID)
sourceId?: string | null;
/** The user that this permission is granted to. */
/** The user that this membership is granted to. */
@BelongsTo(() => User, "userId")
user: User;
/** The user ID that this permission is granted to. */
/** The user ID that this membership is granted to. */
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
/** The user that created this permission. */
/** The user that created this membership. */
@BelongsTo(() => User, "createdById")
createdBy: User;
/** The user ID that created this permission. */
/** The user ID that created this membership. */
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
// static methods
/**
* Copy user memberships from one document to another.
*
* @param where The where clause to find the user memberships to copy.
* @param document The document to copy the user memberships to.
* @param options Additional options to pass to the query.
*/
public static async copy(
where: WhereOptions<UserMembership>,
document: Document,
options: SaveOptions
) {
const { transaction } = options;
const groupMemberships = await this.findAll({
where,
transaction,
});
await Promise.all(
groupMemberships.map((membership) =>
this.create(
{
documentId: document.id,
userId: membership.userId,
sourceId: membership.sourceId ?? membership.id,
permission: membership.permission,
createdById: membership.createdById,
},
{ transaction }
)
)
);
}
/**
* Find the root membership for a document and (optionally) user.
*
@@ -158,6 +195,20 @@ class UserMembership extends IdModel<
return rootMemberships.filter(Boolean) as UserMembership[];
}
// hooks
@AfterCreate
static async createSourcedMemberships(
model: UserMembership,
options: SaveOptions<UserMembership>
) {
if (model.sourceId || !model.documentId) {
return;
}
return this.recreateSourcedMemberships(model, options);
}
@AfterUpdate
static async updateSourcedMemberships(
model: UserMembership,
@@ -176,6 +227,7 @@ class UserMembership extends IdModel<
},
{
where: {
userId: model.userId,
sourceId: model.id,
},
transaction,
@@ -184,18 +236,6 @@ class UserMembership extends IdModel<
}
}
@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.
*/
@@ -210,6 +250,7 @@ class UserMembership extends IdModel<
await this.destroy({
where: {
userId: model.userId,
sourceId: model.id,
},
transaction,

View File

@@ -1,6 +1,6 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow, can } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
@@ -150,7 +150,7 @@ allow(User, ["update", "delete"], Collection, (user, collection) => {
function includesMembership(
collection: Collection | null,
permissions: (CollectionPermission | DocumentPermission)[]
permissions: CollectionPermission[]
) {
if (!collection) {
return false;
@@ -160,9 +160,14 @@ function includesMembership(
collection.memberships,
"Development: collection memberships not preloaded, did you forget `withMembership` scope?"
);
invariant(
collection.groupMemberships,
"Development: collection groupMemberships not preloaded, did you forget `withMembership` scope?"
);
const membershipIds = filter(
[...collection.memberships, ...collection.groupMemberships],
(m) => permissions.includes(m.permission)
(m) => permissions.includes(m.permission as CollectionPermission)
).map((m) => m.id);
return membershipIds.length > 0 ? membershipIds : false;

View File

@@ -1,10 +1,6 @@
import invariant from "invariant";
import filter from "lodash/filter";
import {
CollectionPermission,
DocumentPermission,
TeamPreference,
} from "@shared/types";
import { DocumentPermission, TeamPreference } from "@shared/types";
import { Document, Revision, User, Team } from "@server/models";
import { allow, cannot, can } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
@@ -285,7 +281,7 @@ allow(User, "unpublish", Document, (user, document) => {
function includesMembership(
document: Document | null,
permissions: (DocumentPermission | CollectionPermission)[]
permissions: DocumentPermission[]
) {
if (!document) {
return false;
@@ -293,11 +289,16 @@ function includesMembership(
invariant(
document.memberships,
"document memberships should be preloaded, did you forget withMembership scope?"
"Development: document memberships should be preloaded, did you forget withMembership scope?"
);
invariant(
document.groupMemberships,
"Development: document groupMemberships should be preloaded, did you forget withMembership scope?"
);
const membershipIds = filter(document.memberships, (m) =>
permissions.includes(m.permission)
const membershipIds = filter(
[...document.memberships, ...document.groupMemberships],
(m) => permissions.includes(m.permission as DocumentPermission)
).map((m) => m.id);
return membershipIds.length > 0 ? membershipIds : false;

View File

@@ -1,20 +0,0 @@
import { DocumentPermission } from "@shared/types";
import { GroupMembership } from "@server/models";
type Membership = {
id: string;
groupId: string;
documentId?: string | null;
permission: DocumentPermission;
};
export default function presentDocumentGroupMembership(
membership: GroupMembership
): Membership {
return {
id: membership.id,
groupId: membership.groupId,
documentId: membership.documentId,
permission: membership.permission as DocumentPermission,
};
}

View File

@@ -7,5 +7,6 @@ export default function presentGroupMembership(membership: GroupMembership) {
documentId: membership.documentId,
collectionId: membership.collectionId,
permission: membership.permission,
sourceId: membership.sourceId,
};
}

View File

@@ -6,11 +6,13 @@ import {
CommentEvent,
CollectionUserEvent,
DocumentUserEvent,
DocumentGroupEvent,
} from "@server/types";
import CollectionAddUserNotificationsTask from "../tasks/CollectionAddUserNotificationsTask";
import CollectionCreatedNotificationsTask from "../tasks/CollectionCreatedNotificationsTask";
import CommentCreatedNotificationsTask from "../tasks/CommentCreatedNotificationsTask";
import CommentUpdatedNotificationsTask from "../tasks/CommentUpdatedNotificationsTask";
import DocumentAddGroupNotificationsTask from "../tasks/DocumentAddGroupNotificationsTask";
import DocumentAddUserNotificationsTask from "../tasks/DocumentAddUserNotificationsTask";
import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask";
import RevisionCreatedNotificationsTask from "../tasks/RevisionCreatedNotificationsTask";
@@ -20,6 +22,7 @@ export default class NotificationsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
"documents.add_user",
"documents.add_group",
"revisions.create",
"collections.create",
"collections.add_user",
@@ -33,6 +36,8 @@ export default class NotificationsProcessor extends BaseProcessor {
return this.documentPublished(event);
case "documents.add_user":
return this.documentAddUser(event);
case "documents.add_group":
return this.documentAddGroup(event);
case "revisions.create":
return this.revisionCreated(event);
case "collections.create":
@@ -67,6 +72,13 @@ export default class NotificationsProcessor extends BaseProcessor {
await DocumentAddUserNotificationsTask.schedule(event);
}
async documentAddGroup(event: DocumentGroupEvent) {
if (!event.data.isNew) {
return;
}
await DocumentAddGroupNotificationsTask.schedule(event);
}
async revisionCreated(event: RevisionEvent) {
await RevisionCreatedNotificationsTask.schedule(event);
}

View File

@@ -1,3 +1,4 @@
import uniq from "lodash/uniq";
import { Server } from "socket.io";
import {
Comment,
@@ -152,6 +153,40 @@ export default class WebsocketsProcessor {
return;
}
case "documents.add_group": {
const [document, membership] = await Promise.all([
Document.findByPk(event.documentId),
GroupMembership.findByPk(event.data.membershipId),
]);
if (!document || !membership) {
return;
}
const channels = await this.getDocumentEventChannels(event, document);
socketio
.to(channels)
.emit(event.name, presentGroupMembership(membership));
return;
}
case "documents.remove_group": {
const [document, group] = await Promise.all([
Document.findByPk(event.documentId),
Group.findByPk(event.modelId),
]);
if (!document || !group) {
return;
}
const channels = await this.getDocumentEventChannels(event, document);
socketio.to([...channels, `group-${event.modelId}`]).emit(event.name, {
id: event.data.membershipId,
groupId: event.modelId,
documentId: event.documentId,
});
return;
}
case "collections.create": {
const collection = await Collection.findByPk(event.collectionId, {
paranoid: false,
@@ -284,17 +319,15 @@ export default class WebsocketsProcessor {
}
case "collections.remove_group": {
const membership = {
groupId: event.modelId,
collectionId: event.collectionId,
id: event.data.membershipId,
};
// let everyone with access to the collection know a group was removed
// this includes those in the the group itself
socketio
.to(`collection-${event.collectionId}`)
.emit("collections.remove_group", membership);
.emit("collections.remove_group", {
groupId: event.modelId,
collectionId: event.collectionId,
id: event.data.membershipId,
});
await GroupUser.findAllInBatches<GroupUser>(
{
@@ -482,22 +515,28 @@ export default class WebsocketsProcessor {
},
async (groupMemberships) => {
for (const groupMembership of groupMemberships) {
if (!groupMembership.collectionId) {
continue;
if (groupMembership.collectionId) {
socketio
.to(`user-${event.userId}`)
.emit(
"collections.add_group",
presentGroupMembership(groupMembership)
);
// tell any user clients to connect to the websocket channel for the collection
socketio.to(`user-${event.userId}`).emit("join", {
event: event.name,
collectionId: groupMembership.collectionId,
});
}
if (groupMembership.documentId) {
socketio
.to(`user-${event.userId}`)
.emit(
"documents.add_group",
presentGroupMembership(groupMembership)
);
}
socketio
.to(`user-${event.userId}`)
.emit(
"collections.add_group",
presentGroupMembership(groupMembership)
);
// tell any user clients to connect to the websocket channel for the collection
socketio.to(`user-${event.userId}`).emit("join", {
event: event.name,
collectionId: groupMembership.collectionId,
});
}
}
);
@@ -595,26 +634,33 @@ export default class WebsocketsProcessor {
},
async (groupUsers) => {
for (const groupMembership of groupMemberships) {
if (!groupMembership.collectionId) {
continue;
}
const payload = presentGroupMembership(groupMembership);
for (const groupUser of groupUsers) {
socketio
.to(`user-${groupUser.userId}`)
.emit("collections.remove_group", payload);
if (groupMembership.collectionId) {
for (const groupUser of groupUsers) {
socketio
.to(`user-${groupUser.userId}`)
.emit("collections.remove_group", payload);
const collection = await Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(groupMembership.collectionId);
const collection = await Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(groupMembership.collectionId);
if (cannot(groupUser.user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
socketio.to(`user-${groupUser.userId}`).emit("leave", {
event: event.name,
collectionId: groupMembership.collectionId,
});
if (cannot(groupUser.user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
socketio.to(`user-${groupUser.userId}`).emit("leave", {
event: event.name,
collectionId: groupMembership.collectionId,
});
}
}
}
if (groupMembership.documentId) {
for (const groupUser of groupUsers) {
socketio
.to(`user-${groupUser.userId}`)
.emit("documents.remove_group", payload);
}
}
}
@@ -719,16 +765,27 @@ export default class WebsocketsProcessor {
}
}
const memberships = await UserMembership.findAll({
where: {
documentId: document.id,
},
});
const [userMemberships, groupMemberships] = await Promise.all([
UserMembership.findAll({
where: {
documentId: document.id,
},
}),
GroupMembership.findAll({
where: {
documentId: document.id,
},
}),
]);
for (const membership of memberships) {
for (const membership of userMemberships) {
channels.push(`user-${membership.userId}`);
}
return channels;
for (const membership of groupMemberships) {
channels.push(`group-${membership.groupId}`);
}
return uniq(channels);
}
}

View File

@@ -0,0 +1,27 @@
import { GroupUser } from "@server/models";
import { DocumentGroupEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
export default class DocumentAddGroupNotificationsTask extends BaseTask<DocumentGroupEvent> {
public async perform(event: DocumentGroupEvent) {
const groupUsers = await GroupUser.findAll({
where: {
groupId: event.modelId,
},
});
for (const groupUser of groupUsers) {
await DocumentAddUserNotificationsTask.schedule({
...event,
userId: groupUser.userId,
});
}
}
public get options() {
return {
priority: TaskPriority.Background,
};
}
}

View File

@@ -14,6 +14,8 @@ import {
presentPolicies,
presentProviderConfig,
presentAvailableTeam,
presentGroup,
presentGroupUser,
} from "@server/presenters";
import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask";
import { APIContext } from "@server/types";
@@ -117,10 +119,11 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
const sessions = getSessionsInCookie(ctx);
const signedInTeamIds = Object.keys(sessions);
const [team, signedInTeams, availableTeams] = await Promise.all([
const [team, groups, signedInTeams, availableTeams] = await Promise.all([
Team.scope("withDomains").findByPk(user.teamId, {
rejectOnEmpty: true,
}),
user.groups(),
Team.findAll({
where: {
id: signedInTeamIds,
@@ -141,16 +144,19 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
includeDetails: true,
}),
team: presentTeam(team),
groups: await Promise.all(groups.map(presentGroup)),
groupUsers: groups.map((group) => presentGroupUser(group.groupUsers[0])),
collaborationToken: user.getCollaborationToken(),
availableTeams: uniqBy([...signedInTeams, ...availableTeams], "id").map(
(team) =>
(availableTeam) =>
presentAvailableTeam(
team,
signedInTeamIds.includes(team.id) || team.id === user.teamId
availableTeam,
signedInTeamIds.includes(team.id) ||
availableTeam.id === user.teamId
)
),
},
policies: presentPolicies(user, [team]),
policies: presentPolicies(user, [team, user, ...groups]),
};
});

View File

@@ -645,8 +645,8 @@ describe("#collections.remove_group", () => {
groupId: group.id,
},
});
let users = await collection.$get("groups");
expect(users.length).toEqual(1);
let groups = await collection.$get("groups");
expect(groups.length).toEqual(1);
const res = await server.post("/api/collections.remove_group", {
body: {
token: user.getJwtToken(),
@@ -654,9 +654,9 @@ describe("#collections.remove_group", () => {
groupId: group.id,
},
});
users = await collection.$get("groups");
groups = await collection.$get("groups");
expect(res.status).toEqual(200);
expect(users.length).toEqual(0);
expect(groups.length).toEqual(0);
});
it("should require group in team", async () => {

View File

@@ -217,47 +217,51 @@ router.post(
"collections.add_group",
auth(),
validate(T.CollectionsAddGroupSchema),
transaction(),
async (ctx: APIContext<T.CollectionsAddGroupsReq>) => {
const { id, groupId, permission } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
const [collection, group] = await Promise.all([
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction }),
Group.findByPk(groupId, { transaction }),
]);
authorize(user, "update", collection);
const group = await Group.findByPk(groupId);
authorize(user, "read", group);
let membership = await GroupMembership.findOne({
const [membership] = await GroupMembership.findOrCreate({
where: {
collectionId: id,
groupId,
},
});
if (!membership) {
membership = await GroupMembership.create({
collectionId: id,
groupId,
defaults: {
permission,
createdById: user.id,
});
} else {
membership.permission = permission;
await membership.save();
}
await Event.createFromContext(ctx, {
name: "collections.add_group",
collectionId: collection.id,
modelId: groupId,
data: {
name: group.name,
membershipId: membership.id,
},
transaction,
lock: transaction.LOCK.UPDATE,
});
membership.permission = permission;
await membership.save({ transaction });
await Event.createFromContext(
ctx,
{
name: "collections.add_group",
collectionId: collection.id,
modelId: groupId,
data: {
name: group.name,
membershipId: membership.id,
},
},
{ transaction }
);
const groupMemberships = [presentGroupMembership(membership)];
ctx.body = {
@@ -280,12 +284,17 @@ router.post(
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction });
const [collection, group] = await Promise.all([
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
transaction,
}),
Group.findByPk(groupId, {
transaction,
}),
]);
authorize(user, "update", collection);
const group = await Group.findByPk(groupId, { transaction });
authorize(user, "read", group);
const [membership] = await collection.$get("groupMemberships", {
@@ -297,7 +306,13 @@ router.post(
ctx.throw(400, "This Group is not a part of the collection");
}
await collection.$remove("group", group);
await GroupMembership.destroy({
where: {
collectionId: id,
groupId,
},
transaction,
});
await Event.createFromContext(
ctx,
{
@@ -322,8 +337,8 @@ router.post(
"collections.group_memberships",
auth(),
pagination(),
validate(T.CollectionsGroupMembershipsSchema),
async (ctx: APIContext<T.CollectionsGroupMembershipsReq>) => {
validate(T.CollectionsMembershipsSchema),
async (ctx: APIContext<T.CollectionsMembershipsReq>) => {
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
@@ -391,19 +406,20 @@ router.post(
"collections.add_user",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
transaction(),
validate(T.CollectionsAddUserSchema),
transaction(),
async (ctx: APIContext<T.CollectionsAddUserReq>) => {
const { auth, transaction } = ctx.state;
const actor = auth.user;
const { id, userId, permission } = ctx.input.body;
const collection = await Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction });
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
const user = await User.findByPk(userId);
authorize(actor, "read", user);
const [membership, isNew] = await UserMembership.findOrCreate({
@@ -460,12 +476,13 @@ router.post(
const actor = auth.user;
const { id, userId } = ctx.input.body;
const collection = await Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction });
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
const user = await User.findByPk(userId, { transaction });
authorize(actor, "read", user);
const [membership] = await collection.$get("memberships", {

View File

@@ -94,17 +94,6 @@ export type CollectionsRemoveGroupReq = z.infer<
typeof CollectionsRemoveGroupSchema
>;
export const CollectionsGroupMembershipsSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
query: z.string().optional(),
permission: z.nativeEnum(CollectionPermission).optional(),
}),
});
export type CollectionsGroupMembershipsReq = z.infer<
typeof CollectionsGroupMembershipsSchema
>;
export const CollectionsAddUserSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
userId: z.string().uuid(),

View File

@@ -43,6 +43,9 @@ import {
User,
View,
UserMembership,
Group,
GroupUser,
GroupMembership,
} from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -57,6 +60,8 @@ import {
presentMembership,
presentPublicTeam,
presentUser,
presentGroupMembership,
presentGroup,
} from "@server/presenters";
import DocumentImportTask, {
DocumentImportTaskResponse,
@@ -142,14 +147,36 @@ router.post(
}
if (parentDocumentId) {
const membership = await UserMembership.findOne({
where: {
userId: user.id,
documentId: parentDocumentId,
},
});
const [groupMembership, membership] = await Promise.all([
GroupMembership.findOne({
where: {
documentId: parentDocumentId,
},
include: [
{
model: Group,
required: true,
include: [
{
model: GroupUser,
required: true,
where: {
userId: user.id,
},
},
],
},
],
}),
UserMembership.findOne({
where: {
userId: user.id,
documentId: parentDocumentId,
},
}),
]);
if (membership) {
if (groupMembership || membership) {
delete where.collectionId;
}
@@ -1491,7 +1518,9 @@ router.post(
transaction,
});
document.collection = collection;
if (collection) {
document.collection = collection;
}
ctx.body = {
data: await presentDocument(ctx, document),
@@ -1607,8 +1636,8 @@ router.post(
validate(T.DocumentsRemoveUserSchema),
transaction(),
async (ctx: APIContext<T.DocumentsRemoveUserReq>) => {
const { auth, transaction } = ctx.state;
const actor = auth.user;
const { transaction } = ctx.state;
const { user: actor } = ctx.state.auth;
const { id, userId } = ctx.input.body;
const [document, user] = await Promise.all([
@@ -1657,6 +1686,132 @@ router.post(
}
);
router.post(
"documents.add_group",
auth(),
validate(T.DocumentsAddGroupSchema),
transaction(),
async (ctx: APIContext<T.DocumentsAddGroupsReq>) => {
const { id, groupId, permission } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const [document, group] = await Promise.all([
Document.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
}),
Group.findByPk(groupId, {
rejectOnEmpty: true,
transaction,
}),
]);
authorize(user, "update", document);
authorize(user, "read", group);
const [membership, isNew] = await GroupMembership.findOrCreate({
where: {
documentId: id,
groupId,
},
defaults: {
permission: permission || user.defaultDocumentPermission,
createdById: user.id,
},
lock: transaction.LOCK.UPDATE,
transaction,
});
if (permission) {
membership.permission = permission;
// disconnect from the source if the permission is manually updated
membership.sourceId = null;
await membership.save({ transaction });
}
await Event.createFromContext(
ctx,
{
name: "documents.add_group",
documentId: document.id,
modelId: groupId,
data: {
title: document.title,
isNew,
permission: membership.permission,
membershipId: membership.id,
},
},
{ transaction }
);
ctx.body = {
data: {
groupMemberships: [presentGroupMembership(membership)],
},
};
}
);
router.post(
"documents.remove_group",
auth(),
validate(T.DocumentsRemoveGroupSchema),
transaction(),
async (ctx: APIContext<T.DocumentsRemoveGroupReq>) => {
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const { id, groupId } = ctx.input.body;
const [document, group] = await Promise.all([
Document.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
}),
Group.findByPk(groupId, {
rejectOnEmpty: true,
transaction,
}),
]);
authorize(user, "update", document);
authorize(user, "read", group);
const membership = await GroupMembership.findOne({
where: {
documentId: id,
groupId,
},
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
await membership.destroy({ transaction });
await Event.createFromContext(
ctx,
{
name: "documents.remove_group",
documentId: document.id,
modelId: groupId,
data: {
name: group.name,
membershipId: membership.id,
},
},
{ transaction }
);
ctx.body = {
success: true,
};
}
);
router.post(
"documents.memberships",
auth(),
@@ -1718,6 +1873,71 @@ router.post(
}
);
router.post(
"documents.group_memberships",
auth(),
pagination(),
validate(T.DocumentsMembershipsSchema),
async (ctx: APIContext<T.DocumentsMembershipsReq>) => {
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "update", document);
let where: WhereOptions<GroupMembership> = {
documentId: id,
};
let groupWhere;
if (query) {
groupWhere = {
name: {
[Op.iLike]: `%${query}%`,
},
};
}
if (permission) {
where = { ...where, permission };
}
const options = {
where,
include: [
{
model: Group,
as: "group",
where: groupWhere,
required: true,
},
],
};
const [total, memberships] = await Promise.all([
GroupMembership.count(options),
GroupMembership.findAll({
...options,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
]);
const groupMemberships = memberships.map(presentGroupMembership);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: {
groupMemberships,
groups: await Promise.all(
memberships.map((membership) => presentGroup(membership.group))
),
},
};
}
);
router.post(
"documents.empty_trash",
auth({ role: UserRole.Admin }),

View File

@@ -394,6 +394,27 @@ export const DocumentsRemoveUserSchema = BaseSchema.extend({
export type DocumentsRemoveUserReq = z.infer<typeof DocumentsRemoveUserSchema>;
export const DocumentsAddGroupSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
groupId: z.string().uuid(),
permission: z
.nativeEnum(DocumentPermission)
.default(DocumentPermission.ReadWrite),
}),
});
export type DocumentsAddGroupsReq = z.infer<typeof DocumentsAddGroupSchema>;
export const DocumentsRemoveGroupSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
groupId: z.string().uuid(),
}),
});
export type DocumentsRemoveGroupReq = z.infer<
typeof DocumentsRemoveGroupSchema
>;
export const DocumentsSharedWithUserSchema = BaseSchema.extend({
body: DocumentsSortParamsSchema,
});
@@ -403,8 +424,7 @@ export type DocumentsSharedWithUserReq = z.infer<
>;
export const DocumentsMembershipsSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
body: BaseIdSchema.extend({
query: z.string().optional(),
permission: z.nativeEnum(DocumentPermission).optional(),
}),

View File

@@ -0,0 +1,70 @@
import { GroupUser } from "@server/models";
import {
buildCollection,
buildDocument,
buildGroup,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("groupMemberships.list", () => {
it("should require authentication", async () => {
const res = await server.post("/api/groupMemberships.list", {
body: {},
});
expect(res.status).toEqual(401);
});
it("should return the list of docs shared with group", 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 group = await buildGroup({
teamId: user.teamId,
});
const member = await buildUser({
teamId: user.teamId,
});
await GroupUser.create({
groupId: group.id,
userId: member.id,
createdById: user.id,
});
await server.post("/api/documents.add_group", {
body: {
token: user.getJwtToken(),
id: document.id,
groupId: group.id,
},
});
const res = await server.post("/api/groupMemberships.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.groupMemberships).not.toBeFalsy();
expect(body.data.groupMemberships).toHaveLength(1);
const sharedDoc = body.data.documents[0];
expect(sharedDoc.id).toEqual(document.id);
expect(sharedDoc.id).toEqual(body.data.groupMemberships[0].documentId);
expect(body.data.groupMemberships[0].groupId).toEqual(group.id);
expect(body.policies).not.toBeFalsy();
});
});

View File

@@ -0,0 +1,94 @@
import Router from "koa-router";
import uniqBy from "lodash/uniqBy";
import { Op } from "sequelize";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Document, GroupMembership } from "@server/models";
import {
presentDocument,
presentGroup,
presentGroupMembership,
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(
"groupMemberships.list",
auth(),
pagination(),
validate(T.GroupMembershipsListSchema),
async (ctx: APIContext<T.GroupMembershipsListReq>) => {
const { groupId } = ctx.input.body;
const { user } = ctx.state.auth;
const memberships = await GroupMembership.findAll({
where: {
documentId: {
[Op.ne]: null,
},
sourceId: {
[Op.eq]: null,
},
},
include: [
{
association: "group",
required: true,
where: groupId ? { id: groupId } : undefined,
include: [
{
association: "groupUsers",
required: true,
where: {
userId: user.id,
},
},
],
},
],
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 groups = uniqBy(
memberships.map((membership) => membership.group),
"id"
);
const policies = presentPolicies(user, [
...documents,
...memberships,
...groups,
]);
ctx.body = {
pagination: ctx.state.pagination,
data: {
groups: await Promise.all(groups.map(presentGroup)),
groupMemberships: memberships.map(presentGroupMembership),
documents: await Promise.all(
documents.map((document: Document) => presentDocument(ctx, document))
),
},
policies,
};
}
);
export default router;

View File

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

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const GroupMembershipsListSchema = BaseSchema.extend({
body: z.object({
groupId: z.string().uuid().optional(),
}),
});
export type GroupMembershipsListReq = z.infer<
typeof GroupMembershipsListSchema
>;

View File

@@ -18,6 +18,7 @@ import developer from "./developer";
import documents from "./documents";
import events from "./events";
import fileOperationsRoute from "./fileOperations";
import groupMemberships from "./groupMemberships";
import groups from "./groups";
import integrations from "./integrations";
import apiResponse from "./middlewares/apiResponse";
@@ -85,6 +86,7 @@ router.use("/", notifications.routes());
router.use("/", attachments.routes());
router.use("/", cron.routes());
router.use("/", groups.routes());
router.use("/", groupMemberships.routes());
router.use("/", fileOperationsRoute.routes());
router.use("/", urls.routes());
router.use("/", userMemberships.routes());

View File

@@ -275,7 +275,12 @@ export type DocumentGroupEvent = BaseEvent<GroupMembership> & {
name: "documents.add_group" | "documents.remove_group";
documentId: string;
modelId: string;
data: { name: string };
data: {
title: string;
isNew?: boolean;
permission?: DocumentPermission;
membershipId: string;
};
};
export type CollectionEvent = BaseEvent<Collection> &

View File

@@ -312,6 +312,8 @@
"Has access through <2>parent</2>": "Has access through <2>parent</2>",
"Suspended": "Suspended",
"Invited": "Invited",
"Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in",
"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",
@@ -322,9 +324,11 @@
"Allow anyone with the link to access": "Allow anyone with the link to access",
"Publish to internet": "Publish to internet",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
"{{ userName }} was invited to the document": "{{ userName }} was invited to the document",
"{{ count }} people invited to the document": "{{ count }} people invited to the document",
"{{ count }} people invited to the document_plural": "{{ count }} people invited to the document",
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
"{{ count }} people added to the document": "{{ count }} people added to the document",
"{{ count }} people added to the document_plural": "{{ count }} people added to the document",
"{{ count }} groups added to the document": "{{ count }} groups added to the document",
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
"Logo": "Logo",
"Move document": "Move document",
"New doc": "New doc",
@@ -666,8 +670,6 @@
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in",
"Admin": "Admin",
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.",