mirror of
https://github.com/outline/outline.git
synced 2025-12-21 10:39:41 -06:00
feat: Invite groups to documents (#7275)
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
35
app/components/Avatar/GroupAvatar.tsx
Normal file
35
app/components/Avatar/GroupAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
47
app/components/Sharing/components/Placeholder.tsx
Normal file
47
app/components/Sharing/components/Placeholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
|
||||
props.$root &&
|
||||
css`
|
||||
opacity: 0;
|
||||
left: -16px;
|
||||
left: -18px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
|
||||
48
app/components/Sidebar/components/GroupLink.tsx
Normal file
48
app/components/Sidebar/components/GroupLink.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
9
app/components/Sidebar/components/SidebarContext.ts
Normal file
9
app/components/Sidebar/components/SidebarContext.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
12
app/components/Sidebar/hooks/useLocationState.ts
Normal file
12
app/components/Sidebar/hooks/useLocationState.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -7,5 +7,6 @@ export default function presentGroupMembership(membership: GroupMembership) {
|
||||
documentId: membership.documentId,
|
||||
collectionId: membership.collectionId,
|
||||
permission: membership.permission,
|
||||
sourceId: membership.sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
27
server/queues/tasks/DocumentAddGroupNotificationsTask.ts
Normal file
27
server/queues/tasks/DocumentAddGroupNotificationsTask.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
70
server/routes/api/groupMemberships/groupMemberships.test.ts
Normal file
70
server/routes/api/groupMemberships/groupMemberships.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
94
server/routes/api/groupMemberships/groupMemberships.ts
Normal file
94
server/routes/api/groupMemberships/groupMemberships.ts
Normal 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;
|
||||
1
server/routes/api/groupMemberships/index.ts
Normal file
1
server/routes/api/groupMemberships/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./groupMemberships";
|
||||
12
server/routes/api/groupMemberships/schema.ts
Normal file
12
server/routes/api/groupMemberships/schema.ts
Normal 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
|
||||
>;
|
||||
@@ -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());
|
||||
|
||||
@@ -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> &
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user