feat: Add read-only collections (#1991)

closes #1017
This commit is contained in:
Tom Moor 2021-03-30 21:02:08 -07:00 committed by GitHub
parent d7acf616cf
commit 7e1b07ef98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 940 additions and 558 deletions

View File

@ -4,7 +4,6 @@ import {
ArchiveIcon,
EditIcon,
GoToIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
@ -103,11 +102,6 @@ const Breadcrumb = ({ document, children, onlyText }: Props) => {
if (onlyText === true) {
return (
<>
{collection.private && (
<>
<SmallPadlockIcon color="currentColor" size={16} />{" "}
</>
)}
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
@ -154,11 +148,6 @@ export const Slash = styled(GoToIcon)`
fill: ${(props) => props.theme.divider};
`;
const SmallPadlockIcon = styled(PadlockIcon)`
display: inline-block;
vertical-align: sub;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;

11
app/components/Divider.js Normal file
View File

@ -0,0 +1,11 @@
// @flow
import styled from "styled-components";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 0;
padding: 0;
`;
export default Divider;

View File

@ -1,6 +1,7 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
@ -17,7 +18,8 @@ type Props = {
group: Group,
groupMemberships: GroupMembershipsStore,
membership?: CollectionGroupMembership,
showFacepile: boolean,
showFacepile?: boolean,
showAvatar?: boolean,
renderActions: ({ openMembersModal: () => void }) => React.Node,
};
@ -48,6 +50,11 @@ class GroupListItem extends React.Component<Props> {
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={28} />
</Image>
}
title={
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
}
@ -84,6 +91,15 @@ class GroupListItem extends React.Component<Props> {
}
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: ${(props) => props.theme.secondaryBackground};
border-radius: 20px;
`;
const Title = styled.span`
&:hover {
text-decoration: underline;

View File

@ -27,7 +27,7 @@ const Wrapper = styled.label`
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
type Option = { label: string, value: string };
export type Option = { label: string, value: string };
export type Props = {
value?: string,

View File

@ -0,0 +1,22 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<Props, { options: Array<Option> }>
) {
const { t } = useTranslation();
return (
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "" },
]}
{...props}
/>
);
}

View File

@ -17,12 +17,10 @@ const Labeled = ({ label, children, ...props }: Props) => (
);
export const Label = styled(Flex)`
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
color: ${(props) => props.theme.textTertiary};
letter-spacing: 0.04em;
padding-bottom: 4px;
display: inline-block;
color: ${(props) => props.theme.text};
`;
export default observer(Labeled);

View File

@ -27,7 +27,7 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
const Wrapper = styled.li`
display: flex;
padding: ${(props) => (props.compact ? "8px" : "12px")} 0;
padding: 8px 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};

View File

@ -9,7 +9,7 @@ import Collection from "models/Collection";
import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionMembers from "scenes/CollectionMembers";
import CollectionPermissions from "scenes/CollectionPermissions";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
@ -42,9 +42,10 @@ function CollectionMenu({
const history = useHistory();
const file = React.useRef<?HTMLInputElement>();
const [showCollectionMembers, setShowCollectionMembers] = React.useState(
false
);
const [
showCollectionPermissions,
setShowCollectionPermissions,
] = React.useState(false);
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
@ -155,9 +156,9 @@ function CollectionMenu({
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Members")}`,
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionMembers(true),
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
@ -178,15 +179,11 @@ function CollectionMenu({
{renderModals && (
<>
<Modal
title={t("Collection members")}
onRequestClose={() => setShowCollectionMembers(false)}
isOpen={showCollectionMembers}
title={t("Collection permissions")}
onRequestClose={() => setShowCollectionPermissions(false)}
isOpen={showCollectionPermissions}
>
<CollectionMembers
collection={collection}
onSubmit={() => setShowCollectionMembers(false)}
onEdit={() => setShowCollectionEdit(true)}
/>
<CollectionPermissions collection={collection} />
</Modal>
<Modal
title={t("Edit collection")}

View File

@ -15,7 +15,7 @@ export default class Collection extends BaseModel {
description: string;
icon: string;
color: string;
private: boolean;
permission: "read" | "read_write" | void;
sharing: boolean;
index: string;
documents: NavigationNode[];
@ -25,11 +25,6 @@ export default class Collection extends BaseModel {
sort: { field: string, direction: "asc" | "desc" };
url: string;
@computed
get isPrivate(): boolean {
return this.private;
}
@computed
get isEmpty(): boolean {
return this.documents.length === 0;
@ -121,7 +116,7 @@ export default class Collection extends BaseModel {
"description",
"sharing",
"icon",
"private",
"permission",
"sort",
"index",
]);

View File

@ -12,8 +12,7 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionMembers from "scenes/CollectionMembers";
import CollectionPermissions from "scenes/CollectionPermissions";
import Search from "scenes/Search";
import { Action, Separator } from "components/Actions";
import Badge from "components/Badge";
@ -53,7 +52,6 @@ class CollectionScene extends React.Component<Props> {
@observable collection: ?Collection;
@observable isFetching: boolean = true;
@observable permissionsModalOpen: boolean = false;
@observable editModalOpen: boolean = false;
componentDidMount() {
const { id } = this.props.match.params;
@ -113,14 +111,6 @@ class CollectionScene extends React.Component<Props> {
this.permissionsModalOpen = false;
};
handleEditModalOpen = () => {
this.editModalOpen = true;
};
handleEditModalClose = () => {
this.editModalOpen = false;
};
renderActions() {
const { match, policies, t } = this.props;
const can = policies.abilities(match.params.id || "");
@ -221,32 +211,16 @@ class CollectionScene extends React.Component<Props> {
</Button>
</Link>
&nbsp;&nbsp;
{collection.private && (
<Button onClick={this.onPermissions} neutral>
{t("Manage members")}
</Button>
)}
<Button onClick={this.onPermissions} neutral>
{t("Manage permissions")}
</Button>
</Empty>
<Modal
title={t("Collection members")}
title={t("Collection permissions")}
onRequestClose={this.handlePermissionsModalClose}
isOpen={this.permissionsModalOpen}
>
<CollectionMembers
collection={this.collection}
onSubmit={this.handlePermissionsModalClose}
onEdit={this.handleEditModalOpen}
/>
</Modal>
<Modal
title={t("Edit collection")}
onRequestClose={this.handleEditModalClose}
isOpen={this.editModalOpen}
>
<CollectionEdit
collection={this.collection}
onSubmit={this.handleEditModalClose}
/>
<CollectionPermissions collection={this.collection} />
</Modal>
</Centered>
) : (
@ -254,10 +228,10 @@ class CollectionScene extends React.Component<Props> {
<Heading>
<CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}{" "}
{collection.private && (
{!collection.permission && (
<Tooltip
tooltip={t(
"This collection is only visible to people given access"
"This collection is only visible to those given access"
)}
placement="bottom"
>

View File

@ -28,7 +28,6 @@ class CollectionEdit extends React.Component<Props> {
@observable sharing: boolean = this.props.collection.sharing;
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
@observable sort: { field: string, direction: "asc" | "desc" } = this.props
.collection.sort;
@observable isSaving: boolean;
@ -43,7 +42,6 @@ class CollectionEdit extends React.Component<Props> {
name: this.name,
icon: this.icon,
color: this.color,
private: this.private,
sharing: this.sharing,
sort: this.sort,
});
@ -75,10 +73,6 @@ class CollectionEdit extends React.Component<Props> {
this.icon = icon;
};
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
this.private = ev.target.checked;
};
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
this.sharing = ev.target.checked;
};
@ -122,17 +116,6 @@ class CollectionEdit extends React.Component<Props> {
value={`${this.sort.field}.${this.sort.direction}`}
onChange={this.handleSortChange}
/>
<Switch
id="private"
label={t("Private collection")}
onChange={this.handlePrivateChange}
checked={this.private}
/>
<HelpText>
<Trans>
A private collection will only be visible to invited team members.
</Trans>
</HelpText>
<Switch
id="sharing"
label={t("Public document sharing")}

View File

@ -1,268 +0,0 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Collection from "models/Collection";
import Button from "components/Button";
import ButtonLink from "components/ButtonLink";
import Empty from "components/Empty";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Subheading from "components/Subheading";
import AddGroupsToCollection from "./AddGroupsToCollection";
import AddPeopleToCollection from "./AddPeopleToCollection";
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
import MemberListItem from "./components/MemberListItem";
type Props = {
ui: UiStore,
auth: AuthStore,
collection: Collection,
users: UsersStore,
memberships: MembershipsStore,
collectionGroupMemberships: CollectionGroupMembershipsStore,
groups: GroupsStore,
onEdit: () => void,
};
@observer
class CollectionMembers extends React.Component<Props> {
@observable addGroupModalOpen: boolean = false;
@observable addMemberModalOpen: boolean = false;
handleAddGroupModalOpen = () => {
this.addGroupModalOpen = true;
};
handleAddGroupModalClose = () => {
this.addGroupModalOpen = false;
};
handleAddMemberModalOpen = () => {
this.addMemberModalOpen = true;
};
handleAddMemberModalClose = () => {
this.addMemberModalOpen = false;
};
handleRemoveUser = (user) => {
try {
this.props.memberships.delete({
collectionId: this.props.collection.id,
userId: user.id,
});
this.props.ui.showToast(`${user.name} was removed from the collection`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not remove user", { type: "error" });
}
};
handleUpdateUser = (user, permission) => {
try {
this.props.memberships.create({
collectionId: this.props.collection.id,
userId: user.id,
permission,
});
this.props.ui.showToast(`${user.name} permissions were updated`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not update user", { type: "error" });
}
};
handleRemoveGroup = (group) => {
try {
this.props.collectionGroupMemberships.delete({
collectionId: this.props.collection.id,
groupId: group.id,
});
this.props.ui.showToast(`${group.name} was removed from the collection`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not remove group", { type: "error" });
}
};
handleUpdateGroup = (group, permission) => {
try {
this.props.collectionGroupMemberships.create({
collectionId: this.props.collection.id,
groupId: group.id,
permission,
});
this.props.ui.showToast(`${group.name} permissions were updated`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not update user", { type: "error" });
}
};
render() {
const {
collection,
users,
groups,
memberships,
collectionGroupMemberships,
auth,
} = this.props;
const { user } = auth;
if (!user) return null;
const key = memberships.orderedData
.map((m) => m.permission)
.concat(collection.private)
.join("-");
return (
<Flex column>
{collection.private ? (
<>
<HelpText>
Choose which groups and team members have access to view and edit
documents in the private <strong>{collection.name}</strong>{" "}
collection. You can make this collection visible to the entire
team by{" "}
<ButtonLink onClick={this.props.onEdit}>
changing the visibility
</ButtonLink>
.
</HelpText>
<span>
<Button
type="button"
onClick={this.handleAddGroupModalOpen}
icon={<PlusIcon />}
neutral
>
Add groups
</Button>
</span>
</>
) : (
<HelpText>
The <strong>{collection.name}</strong> collection is accessible by
everyone on the team. If you want to limit who can view the
collection,{" "}
<ButtonLink onClick={this.props.onEdit}>make it private</ButtonLink>
.
</HelpText>
)}
{collection.private && (
<GroupsWrap>
<Subheading>Groups</Subheading>
<PaginatedList
key={key}
items={groups.inCollection(collection.id)}
fetch={collectionGroupMemberships.fetchPage}
options={collection.private ? { id: collection.id } : undefined}
empty={<Empty>This collection has no groups.</Empty>}
renderItem={(group) => (
<CollectionGroupMemberListItem
key={group.id}
group={group}
collectionGroupMembership={collectionGroupMemberships.get(
`${group.id}-${collection.id}`
)}
onRemove={() => this.handleRemoveGroup(group)}
onUpdate={(permission) =>
this.handleUpdateGroup(group, permission)
}
/>
)}
/>
<Modal
title={`Add groups to ${collection.name}`}
onRequestClose={this.handleAddGroupModalClose}
isOpen={this.addGroupModalOpen}
>
<AddGroupsToCollection
collection={collection}
onSubmit={this.handleAddGroupModalClose}
/>
</Modal>
</GroupsWrap>
)}
{collection.private ? (
<>
<span>
<Button
type="button"
onClick={this.handleAddMemberModalOpen}
icon={<PlusIcon />}
neutral
>
Add individual members
</Button>
</span>
<Subheading>Individual Members</Subheading>
</>
) : (
<Subheading>Members</Subheading>
)}
<PaginatedList
key={key}
items={
collection.private
? users.inCollection(collection.id)
: users.active
}
fetch={collection.private ? memberships.fetchPage : users.fetchPage}
options={collection.private ? { id: collection.id } : undefined}
renderItem={(item) => (
<MemberListItem
key={item.id}
user={item}
membership={memberships.get(`${item.id}-${collection.id}`)}
canEdit={collection.private && item.id !== user.id}
onRemove={() => this.handleRemoveUser(item)}
onUpdate={(permission) => this.handleUpdateUser(item, permission)}
/>
)}
/>
<Modal
title={`Add people to ${collection.name}`}
onRequestClose={this.handleAddMemberModalClose}
isOpen={this.addMemberModalOpen}
>
<AddPeopleToCollection
collection={collection}
onSubmit={this.handleAddMemberModalClose}
/>
</Modal>
</Flex>
);
}
}
const GroupsWrap = styled.div`
margin-bottom: 50px;
`;
export default inject(
"auth",
"users",
"memberships",
"collectionGroupMemberships",
"groups",
"ui"
)(CollectionMembers);

View File

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

View File

@ -14,6 +14,7 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker, { icons } from "components/IconPicker";
import Input from "components/Input";
import InputSelectPermission from "components/InputSelectPermission";
import Switch from "components/Switch";
type Props = {
@ -31,7 +32,7 @@ class CollectionNew extends React.Component<Props> {
@observable icon: string = "";
@observable color: string = "#4E5C6E";
@observable sharing: boolean = true;
@observable private: boolean = false;
@observable permission: string = "read_write";
@observable isSaving: boolean;
hasOpenedIconPicker: boolean = false;
@ -44,7 +45,7 @@ class CollectionNew extends React.Component<Props> {
sharing: this.sharing,
icon: this.icon,
color: this.color,
private: this.private,
permission: this.permission,
},
this.props.collections
);
@ -87,8 +88,8 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.private = ev.target.checked;
handlePermissionChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.permission = ev.target.value;
};
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
@ -131,15 +132,16 @@ class CollectionNew extends React.Component<Props> {
icon={this.icon}
/>
</Flex>
<Switch
id="private"
label={t("Private collection")}
onChange={this.handlePrivateChange}
checked={this.private}
<InputSelectPermission
value={this.permission}
onChange={this.handlePermissionChange}
short
/>
<HelpText>
<Trans>
A private collection will only be visible to invited team members.
This is the default level of access given to team members, you can
give specific users or groups more access once the collection is
created.
</Trans>
</HelpText>
{teamSharingEnabled && (

View File

@ -8,14 +8,14 @@ import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect";
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
type Props = {
type Props = {|
group: Group,
collectionGroupMembership: ?CollectionGroupMembership,
onUpdate: (permission: string) => void,
onRemove: () => void,
};
onUpdate: (permission: string) => any,
onRemove: () => any,
|};
const MemberListItem = ({
const CollectionGroupMemberListItem = ({
group,
collectionGroupMembership,
onUpdate,
@ -25,8 +25,8 @@ const MemberListItem = ({
const PERMISSIONS = React.useMemo(
() => [
{ label: t("Read only"), value: "read" },
{ label: t("Read & Edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("View and edit"), value: "read_write" },
],
[t]
);
@ -36,6 +36,7 @@ const MemberListItem = ({
group={group}
onRemove={onRemove}
onUpdate={onUpdate}
showAvatar
renderActions={({ openMembersModal }) => (
<>
<Select
@ -48,13 +49,11 @@ const MemberListItem = ({
}
onChange={(ev) => onUpdate(ev.target.value)}
labelHidden
/>{" "}
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
<ButtonWrap>
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
</ButtonWrap>
</>
)}
/>
@ -64,10 +63,7 @@ const MemberListItem = ({
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
`;
const ButtonWrap = styled.div`
margin-left: 6px;
`;
export default MemberListItem;
export default CollectionGroupMemberListItem;

View File

@ -17,9 +17,9 @@ type Props = {
user: User,
membership?: ?Membership,
canEdit: boolean,
onAdd?: () => void,
onRemove?: () => void,
onUpdate?: (permission: string) => void,
onAdd?: () => any,
onRemove?: () => any,
onUpdate?: (permission: string) => any,
};
const MemberListItem = ({
@ -34,8 +34,8 @@ const MemberListItem = ({
const PERMISSIONS = React.useMemo(
() => [
{ label: t("Read only"), value: "read" },
{ label: t("Read & Edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("View and edit"), value: "read_write" },
],
[t]
);
@ -67,8 +67,7 @@ const MemberListItem = ({
onChange={(ev) => onUpdate(ev.target.value)}
labelHidden
/>
)}
&nbsp;&nbsp;
)}{" "}
{canEdit && onRemove && <MemberMenu onRemove={onRemove} />}
{canEdit && onAdd && (
<Button onClick={onAdd} neutral>
@ -84,6 +83,7 @@ const MemberListItem = ({
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
`;
export default MemberListItem;

View File

@ -12,7 +12,7 @@ import Time from "components/Time";
type Props = {
user: User,
canEdit: boolean,
onAdd: () => void,
onAdd: () => any,
};
const UserListItem = ({ user, onAdd, canEdit }: Props) => {

View File

@ -0,0 +1,283 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Button from "components/Button";
import Divider from "components/Divider";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import InputSelectPermission from "components/InputSelectPermission";
import Labeled from "components/Labeled";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import AddGroupsToCollection from "./AddGroupsToCollection";
import AddPeopleToCollection from "./AddPeopleToCollection";
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
import MemberListItem from "./components/MemberListItem";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
type Props = {|
collection: Collection,
|};
function CollectionPermissions({ collection }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
ui,
memberships,
collectionGroupMemberships,
users,
groups,
} = useStores();
const [addGroupModalOpen, setAddGroupModalOpen] = React.useState(false);
const [addMemberModalOpen, setAddMemberModalOpen] = React.useState(false);
const handleRemoveUser = React.useCallback(
async (user) => {
try {
await memberships.delete({
collectionId: collection.id,
userId: user.id,
});
ui.showToast(
t(`{{ userName }} was removed from the collection`, {
userName: user.name,
}),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not remove user"), { type: "error" });
}
},
[memberships, ui, collection, t]
);
const handleUpdateUser = React.useCallback(
async (user, permission) => {
try {
await memberships.create({
collectionId: collection.id,
userId: user.id,
permission,
});
ui.showToast(
t(`{{ userName }} permissions were updated`, { userName: user.name }),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[memberships, ui, collection, t]
);
const handleRemoveGroup = React.useCallback(
async (group) => {
try {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: group.id,
});
ui.showToast(
t(`The {{ groupName }} group was removed from the collection`, {
groupName: group.name,
}),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not remove group"), { type: "error" });
}
},
[collectionGroupMemberships, ui, collection, t]
);
const handleUpdateGroup = React.useCallback(
async (group, permission) => {
try {
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: group.id,
permission,
});
ui.showToast(
t(`{{ groupName }} permissions were updated`, {
groupName: group.name,
}),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[collectionGroupMemberships, ui, collection, t]
);
const handleChangePermission = React.useCallback(
async (ev) => {
try {
await collection.save({ permission: ev.target.value });
ui.showToast(t("Default access permissions were updated"), {
type: "success",
});
} catch (err) {
ui.showToast(t("Could not update permissions"), { type: "error" });
}
},
[collection, ui, t]
);
const fetchOptions = React.useMemo(() => ({ id: collection.id }), [
collection.id,
]);
const collectionName = collection.name;
const collectionGroups = groups.inCollection(collection.id);
const collectionUsers = users.inCollection(collection.id);
const isEmpty = !collectionGroups.length && !collectionUsers.length;
return (
<Flex column>
<InputSelectPermission
onChange={handleChangePermission}
value={collection.permission || ""}
short
/>
<PermissionExplainer>
{!collection.permission && (
<Trans
defaults="The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
)}
{collection.permission === "read" && (
<Trans
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
)}
{collection.permission === "read_write" && (
<Trans
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
)}
</PermissionExplainer>
<Labeled label={t("Additional access")}>
<Actions>
<Button
type="button"
onClick={() => setAddGroupModalOpen(true)}
icon={<PlusIcon />}
neutral
>
{t("Add groups")}
</Button>{" "}
<Button
type="button"
onClick={() => setAddMemberModalOpen(true)}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</Actions>
</Labeled>
<Divider />
{isEmpty && (
<Empty>
<Trans>
Add specific access for individual groups and team members
</Trans>
</Empty>
)}
<PaginatedList
items={collectionGroups}
fetch={collectionGroupMemberships.fetchPage}
options={fetchOptions}
renderItem={(group) => (
<CollectionGroupMemberListItem
key={group.id}
group={group}
collectionGroupMembership={collectionGroupMemberships.get(
`${group.id}-${collection.id}`
)}
onRemove={() => handleRemoveGroup(group)}
onUpdate={(permission) => handleUpdateGroup(group, permission)}
/>
)}
/>
{collectionGroups.length ? <Divider /> : null}
<PaginatedList
items={collectionUsers}
fetch={memberships.fetchPage}
options={fetchOptions}
renderItem={(item) => (
<MemberListItem
key={item.id}
user={item}
membership={memberships.get(`${item.id}-${collection.id}`)}
canEdit={item.id !== user.id}
onRemove={() => handleRemoveUser(item)}
onUpdate={(permission) => handleUpdateUser(item, permission)}
/>
)}
/>
<Modal
title={t(`Add groups to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={() => setAddGroupModalOpen(false)}
isOpen={addGroupModalOpen}
>
<AddGroupsToCollection
collection={collection}
onSubmit={() => setAddGroupModalOpen(false)}
/>
</Modal>
<Modal
title={t(`Add people to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={() => setAddMemberModalOpen(false)}
isOpen={addMemberModalOpen}
>
<AddPeopleToCollection
collection={collection}
onSubmit={() => setAddMemberModalOpen(false)}
/>
</Modal>
</Flex>
);
}
const Empty = styled(HelpText)`
margin-top: 8px;
`;
const PermissionExplainer = styled(HelpText)`
margin-top: -8px;
margin-bottom: 24px;
`;
const Actions = styled.div`
margin-bottom: 12px;
`;
export default observer(CollectionPermissions);

View File

@ -74,7 +74,7 @@ class GroupMembers extends React.Component<Props> {
<HelpText>
Add and remove team members in the <strong>{group.name}</strong>{" "}
group. Adding people to the group will give them access to any
collections this group has been given access to.
collections this group has been added to.
</HelpText>
<span>
<Button

View File

@ -47,16 +47,6 @@ export default class CollectionsStore extends BaseStore<Collection> {
});
}
@computed
get public(): Collection[] {
return this.orderedData.filter((collection) => !collection.private);
}
@computed
get private(): Collection[] {
return this.orderedData.filter((collection) => collection.private);
}
/**
* List of paths to each of the documents, where paths are composed of id and title/name pairs
*/

View File

@ -17,6 +17,7 @@
"sequelize:migrate": "sequelize db:migrate",
"db:create-migration": "sequelize migration:create",
"db:migrate": "sequelize db:migrate",
"db:rollback": "sequelize db:migrate:undo",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "yarn test:app && yarn test:server",
"test:app": "jest",

View File

@ -112,7 +112,7 @@ describe("#attachments.delete", () => {
it("should not allow deleting an attachment belonging to a document user does not have access to", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
});
const document = await buildDocument({
teamId: collection.teamId,
@ -184,7 +184,7 @@ describe("#attachments.redirect", () => {
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
private: true,
permission: null,
});
const document = await buildDocument({
teamId: user.teamId,
@ -208,7 +208,7 @@ describe("#attachments.redirect", () => {
it("should not return a redirect for a private attachment belonging to a document user does not have access to", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
});
const document = await buildDocument({
teamId: collection.teamId,

View File

@ -39,13 +39,13 @@ router.post("collections.create", auth(), async (ctx) => {
name,
color,
description,
permission,
sharing,
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
let { index } = ctx.body;
const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required");
if (color) {
@ -89,7 +89,7 @@ router.post("collections.create", auth(), async (ctx) => {
color,
teamId: user.teamId,
createdById: user.id,
private: isPrivate,
permission: permission ? permission : null,
sharing,
sort,
index,
@ -105,11 +105,9 @@ router.post("collections.create", auth(), async (ctx) => {
});
// we must reload the collection to get memberships for policy presenter
if (isPrivate) {
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
}
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
ctx.body = {
data: presentCollection(collection),
@ -514,8 +512,16 @@ router.post("collections.export_all", auth(), async (ctx) => {
});
router.post("collections.update", auth(), async (ctx) => {
let { id, name, description, icon, color, sort, sharing } = ctx.body;
const isPrivate = ctx.body.private;
let {
id,
name,
description,
icon,
permission,
color,
sort,
sharing,
} = ctx.body;
if (color) {
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
@ -528,9 +534,9 @@ router.post("collections.update", auth(), async (ctx) => {
authorize(user, "update", collection);
// we're making this collection private right now, ensure that the current
// we're making this collection have no default access, ensure that the current
// user has a read-write membership so that at least they can edit it
if (isPrivate && !collection.private) {
if (permission !== "read_write" && collection.permission === "read_write") {
await CollectionUser.findOrCreate({
where: {
collectionId: collection.id,
@ -543,7 +549,7 @@ router.post("collections.update", auth(), async (ctx) => {
});
}
const isPrivacyChanged = isPrivate !== collection.private;
const permissionChanged = permission !== collection.permission;
if (name !== undefined) {
collection.name = name;
@ -557,8 +563,8 @@ router.post("collections.update", auth(), async (ctx) => {
if (color !== undefined) {
collection.color = color;
}
if (isPrivate !== undefined) {
collection.private = isPrivate;
if (permission !== undefined) {
collection.permission = permission ? permission : null;
}
if (sharing !== undefined) {
collection.sharing = sharing;
@ -580,7 +586,7 @@ router.post("collections.update", auth(), async (ctx) => {
// must reload to update collection membership for correct policy calculation
// if the privacy level has changed. Otherwise skip this query for speed.
if (isPrivacyChanged) {
if (permissionChanged) {
await collection.reload();
}

View File

@ -42,7 +42,7 @@ describe("#collections.list", () => {
it("should not return private collections actor is not a member of", async () => {
const { user, collection } = await seed();
await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
const res = await server.post("/api/collections.list", {
@ -58,12 +58,12 @@ describe("#collections.list", () => {
it("should return private collections actor is a member of", async () => {
const user = await buildUser();
await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
userId: user.id,
});
await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
userId: user.id,
});
@ -82,13 +82,13 @@ describe("#collections.list", () => {
it("should return private collections actor is a group-member of", async () => {
const user = await buildUser();
await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
userId: user.id,
});
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
@ -256,7 +256,7 @@ describe("#collections.export", () => {
it("should now allow export of private collection not a member", async () => {
const { user } = await seed();
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
const res = await server.post("/api/collections.export", {
@ -268,7 +268,7 @@ describe("#collections.export", () => {
it("should allow export of private collection when the actor is a member", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -288,7 +288,7 @@ describe("#collections.export", () => {
it("should allow export of private collection when the actor is a group member", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
@ -369,7 +369,7 @@ describe("#collections.add_user", () => {
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
private: true,
permission: null,
});
const anotherUser = await buildUser({ teamId: user.teamId });
const res = await server.post("/api/collections.add_user", {
@ -389,7 +389,7 @@ describe("#collections.add_user", () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
private: true,
permission: null,
});
const anotherUser = await buildUser();
const res = await server.post("/api/collections.add_user", {
@ -433,7 +433,7 @@ describe("#collections.add_group", () => {
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
private: true,
permission: null,
});
const group = await buildGroup({ teamId: user.teamId });
const res = await server.post("/api/collections.add_group", {
@ -454,7 +454,7 @@ describe("#collections.add_group", () => {
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
private: true,
permission: null,
});
const group = await buildGroup();
const res = await server.post("/api/collections.add_group", {
@ -496,7 +496,7 @@ describe("#collections.remove_group", () => {
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
private: true,
permission: null,
});
const group = await buildGroup({ teamId: user.teamId });
@ -528,7 +528,7 @@ describe("#collections.remove_group", () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
private: true,
permission: null,
});
const group = await buildGroup();
const res = await server.post("/api/collections.remove_group", {
@ -572,7 +572,7 @@ describe("#collections.remove_user", () => {
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
private: true,
permission: null,
});
const anotherUser = await buildUser({ teamId: user.teamId });
@ -601,7 +601,7 @@ describe("#collections.remove_user", () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
private: true,
permission: null,
});
const anotherUser = await buildUser();
const res = await server.post("/api/collections.remove_user", {
@ -642,7 +642,7 @@ describe("#collections.remove_user", () => {
describe("#collections.users", () => {
it("should return users in private collection", async () => {
const { collection, user } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -684,7 +684,7 @@ describe("#collections.group_memberships", () => {
const user = await buildUser();
const group = await buildGroup({ teamId: user.teamId });
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
@ -721,7 +721,7 @@ describe("#collections.group_memberships", () => {
const group = await buildGroup({ name: "will find", teamId: user.teamId });
const group2 = await buildGroup({ name: "wont find", teamId: user.teamId });
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
@ -766,7 +766,7 @@ describe("#collections.group_memberships", () => {
const group = await buildGroup({ teamId: user.teamId });
const group2 = await buildGroup({ teamId: user.teamId });
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
@ -817,7 +817,7 @@ describe("#collections.group_memberships", () => {
it("should require authorization", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
@ -831,7 +831,7 @@ describe("#collections.group_memberships", () => {
describe("#collections.memberships", () => {
it("should return members in private collection", async () => {
const { collection, user } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -945,7 +945,7 @@ describe("#collections.info", () => {
it("should require user member of collection", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/collections.info", {
@ -956,7 +956,7 @@ describe("#collections.info", () => {
it("should allow user member of collection", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1034,12 +1034,12 @@ describe("#collections.create", () => {
it("should return correct policies with private collection", async () => {
const { user } = await seed();
const res = await server.post("/api/collections.create", {
body: { token: user.getJwtToken(), name: "Test", private: true },
body: { token: user.getJwtToken(), name: "Test", permission: null },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.private).toBeTruthy();
expect(body.data.permission).toEqual(null);
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy();
expect(body.policies[0].abilities.export).toBeTruthy();
@ -1176,11 +1176,11 @@ describe("#collections.update", () => {
it("allows editing individual fields", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, private: true },
body: { token: user.getJwtToken(), id: collection.id, permission: null },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.private).toBe(true);
expect(body.data.permission).toBe(null);
expect(body.data.name).toBe(collection.name);
});
@ -1190,14 +1190,14 @@ describe("#collections.update", () => {
body: {
token: user.getJwtToken(),
id: collection.id,
private: true,
permission: null,
name: "Test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("Test");
expect(body.data.private).toBe(true);
expect(body.data.permission).toBe(null);
// ensure we return with a write level policy
expect(body.policies.length).toBe(1);
@ -1206,7 +1206,7 @@ describe("#collections.update", () => {
it("allows editing from private to non-private collection", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1220,14 +1220,14 @@ describe("#collections.update", () => {
body: {
token: user.getJwtToken(),
id: collection.id,
private: false,
permission: "read_write",
name: "Test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("Test");
expect(body.data.private).toBe(false);
expect(body.data.permission).toBe("read_write");
// ensure we return with a write level policy
expect(body.policies.length).toBe(1);
@ -1236,7 +1236,7 @@ describe("#collections.update", () => {
it("allows editing by read-write collection user", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1258,7 +1258,7 @@ describe("#collections.update", () => {
it("allows editing by read-write collection group user", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
@ -1280,7 +1280,7 @@ describe("#collections.update", () => {
it("does not allow editing by read-only collection user", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1393,7 +1393,7 @@ describe("#collections.delete", () => {
it("allows deleting by read-write collection group user", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
await buildCollection({

View File

@ -50,7 +50,7 @@ describe("#documents.info", () => {
it("should not return published document in collection not a member of", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
const document = await buildDocument({ collectionId: collection.id });
@ -209,7 +209,7 @@ describe("#documents.info", () => {
userId: user.id,
});
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/documents.info", {
@ -282,7 +282,7 @@ describe("#documents.export", () => {
it("should not return published document in collection not a member of", async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
permission: null,
teamId: user.teamId,
});
const document = await buildDocument({ collectionId: collection.id });
@ -400,7 +400,7 @@ describe("#documents.export", () => {
userId: user.id,
});
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/documents.export", {
@ -501,7 +501,7 @@ describe("#documents.list", () => {
it("should not return documents in private collections not a member of", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/documents.list", {
@ -573,7 +573,7 @@ describe("#documents.list", () => {
it("should allow filtering to private collection", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -647,7 +647,7 @@ describe("#documents.pinned", () => {
it("should return pinned documents in private collections member of", async () => {
const { user, collection, document } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
document.pinnedById = user.id;
@ -672,7 +672,7 @@ describe("#documents.pinned", () => {
it("should not return pinned documents in private collections not a member of", async () => {
const collection = await buildCollection({
private: true,
permission: null,
});
const user = await buildUser({ teamId: collection.teamId });
@ -710,7 +710,7 @@ describe("#documents.drafts", () => {
document.publishedAt = null;
await document.save();
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/documents.drafts", {
@ -996,7 +996,7 @@ describe("#documents.search", () => {
it("should return documents for a specific private collection", async () => {
const { user, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1061,7 +1061,7 @@ describe("#documents.search", () => {
it("should not return documents in private collections not a member of", async () => {
const { user } = await seed();
const collection = await buildCollection({ private: true });
const collection = await buildCollection({ permission: null });
await buildDocument({
title: "search term",
@ -1158,7 +1158,7 @@ describe("#documents.archived", () => {
it("should not return documents in private collections not a member of", async () => {
const { user } = await seed();
const collection = await buildCollection({ private: true });
const collection = await buildCollection({ permission: null });
const document = await buildDocument({
teamId: user.teamId,
@ -1224,7 +1224,7 @@ describe("#documents.viewed", () => {
it("should not return recently viewed documents in collection not a member of", async () => {
const { user, document, collection } = await seed();
await View.increment({ documentId: document.id, userId: user.id });
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/documents.viewed", {
@ -1808,7 +1808,7 @@ describe("#documents.update", () => {
document.publishedAt = null;
await document.save();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1903,7 +1903,7 @@ describe("#documents.update", () => {
it("allows editing by read-write collection user", async () => {
const { admin, document, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1931,7 +1931,7 @@ describe("#documents.update", () => {
it("does not allow editing by read-only collection user", async () => {
const { user, document, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -1953,6 +1953,23 @@ describe("#documents.update", () => {
expect(res.status).toEqual(403);
});
it("does not allow editing in read-only collection", async () => {
const { user, document, collection } = await seed();
collection.permission = "read";
await collection.save();
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: document.id,
text: "Changed text",
lastRevision: document.revision,
},
});
expect(res.status).toEqual(403);
});
it("should append document with text", async () => {
const { user, document } = await seed();

View File

@ -66,7 +66,7 @@ describe("#revisions.list", () => {
const { user, document, collection } = await seed();
await Revision.createFromDocument(document);
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/revisions.list", {

View File

@ -115,7 +115,7 @@ describe("#shares.list", () => {
userId: admin.id,
});
collection.private = true;
collection.permission = null;
await collection.save();
const res = await server.post("/api/shares.list", {
@ -151,7 +151,7 @@ describe("#shares.create", () => {
it("should not allow creating a share record with read-only permissions", async () => {
const { user, document, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();

View File

@ -27,7 +27,7 @@ describe("#views.list", () => {
it("should return views for a document in read-only collection", async () => {
const { user, document, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({
@ -84,7 +84,7 @@ describe("#views.create", () => {
it("should allow creating a view record for document in read-only collection", async () => {
const { user, document, collection } = await seed();
collection.private = true;
collection.permission = null;
await collection.save();
await CollectionUser.create({

View File

@ -58,7 +58,7 @@ export default async function collectionImporter({
},
defaults: {
createdById: user.id,
private: false,
permission: "read_write",
},
});
@ -71,7 +71,7 @@ export default async function collectionImporter({
teamId: user.teamId,
createdById: user.id,
name,
private: false,
permission: "read_write",
});
await Event.create({
name: "collections.create",

View File

@ -0,0 +1,38 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("collections", "permission", {
type: Sequelize.STRING,
defaultValue: null,
allowNull: true,
validate: {
isIn: [["read", "read_write"]],
},
});
await queryInterface.sequelize.query(`
UPDATE collections
SET "permission" = 'read_write'
WHERE "private" = false
`);
await queryInterface.removeColumn("collections", "private");
},
down: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("collections", "private", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
await queryInterface.sequelize.query(`
UPDATE collections
SET "private" = true
WHERE "permission" IS NULL
`);
await queryInterface.removeColumn("collections", "permission");
}
};

View File

@ -25,7 +25,14 @@ const Collection = sequelize.define(
type: DataTypes.STRING,
defaultValue: null,
},
private: DataTypes.BOOLEAN,
permission: {
type: DataTypes.STRING,
defaultValue: null,
allowNull: true,
validate: {
isIn: [["read", "read_write"]],
},
},
maintainerApprovalRequired: DataTypes.BOOLEAN,
documentStructure: DataTypes.JSONB,
sharing: {
@ -199,7 +206,7 @@ Collection.addHook("afterDestroy", async (model: Collection) => {
});
Collection.addHook("afterCreate", (model: Collection, options) => {
if (model.private) {
if (model.permission !== "read_write") {
return CollectionUser.findOrCreate({
where: {
collectionId: model.id,

View File

@ -254,7 +254,7 @@ describe("#membershipUserIds", () => {
const collection = await buildCollection({
userId: users[0].id,
private: true,
permission: null,
teamId,
});

View File

@ -178,7 +178,7 @@ describe("#searchForTeam", () => {
test("should not return search results from private collections", async () => {
const team = await buildTeam();
const collection = await buildCollection({
private: true,
permission: null,
teamId: team.id,
});
await buildDocument({

View File

@ -15,11 +15,11 @@ describe("afterDestroy hook", () => {
const user2 = await buildUser({ teamId });
const collection1 = await buildCollection({
private: true,
permission: null,
teamId,
});
const collection2 = await buildCollection({
private: true,
permission: null,
teamId,
});

View File

@ -225,10 +225,16 @@ Team.prototype.activateUser = async function (user: User, admin: User) {
Team.prototype.collectionIds = async function (paranoid: boolean = true) {
let models = await Collection.findAll({
attributes: ["id", "private"],
where: { teamId: this.id, private: false },
attributes: ["id"],
where: {
teamId: this.id,
permission: {
[Op.ne]: null,
},
},
paranoid,
});
return models.map((c) => c.id);
};

View File

@ -1,38 +1,56 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { buildTeam } from "../test/factories";
// @flow
import { buildTeam, buildCollection } from "../test/factories";
import { flushdb } from "../test/support";
beforeEach(() => flushdb());
it("should set subdomain if available", async () => {
const team = await buildTeam();
const subdomain = await team.provisionSubdomain("testy");
expect(subdomain).toEqual("testy");
expect(team.subdomain).toEqual("testy");
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
const collection = await buildCollection({ teamId: team.id });
// build a collection in another team
await buildCollection();
// build a private collection
await buildCollection({ teamId: team.id, permission: null });
const response = await team.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
});
it("should set subdomain append if unavailable", async () => {
await buildTeam({ subdomain: "myteam" });
describe("provisionSubdomain", () => {
it("should set subdomain if available", async () => {
const team = await buildTeam();
const subdomain = await team.provisionSubdomain("testy");
expect(subdomain).toEqual("testy");
expect(team.subdomain).toEqual("testy");
});
const team = await buildTeam();
const subdomain = await team.provisionSubdomain("myteam");
expect(subdomain).toEqual("myteam1");
expect(team.subdomain).toEqual("myteam1");
});
it("should increment subdomain append if unavailable", async () => {
await buildTeam({ subdomain: "myteam" });
await buildTeam({ subdomain: "myteam1" });
const team = await buildTeam();
const subdomain = await team.provisionSubdomain("myteam");
expect(subdomain).toEqual("myteam2");
expect(team.subdomain).toEqual("myteam2");
});
it("should do nothing if subdomain already set", async () => {
const team = await buildTeam({ subdomain: "example" });
const subdomain = await team.provisionSubdomain("myteam");
expect(subdomain).toEqual("example");
expect(team.subdomain).toEqual("example");
it("should set subdomain append if unavailable", async () => {
await buildTeam({ subdomain: "myteam" });
const team = await buildTeam();
const subdomain = await team.provisionSubdomain("myteam");
expect(subdomain).toEqual("myteam1");
expect(team.subdomain).toEqual("myteam1");
});
it("should increment subdomain append if unavailable", async () => {
await buildTeam({ subdomain: "myteam" });
await buildTeam({ subdomain: "myteam1" });
const team = await buildTeam();
const subdomain = await team.provisionSubdomain("myteam");
expect(subdomain).toEqual("myteam2");
expect(team.subdomain).toEqual("myteam2");
});
it("should do nothing if subdomain already set", async () => {
const team = await buildTeam({ subdomain: "example" });
const subdomain = await team.provisionSubdomain("myteam");
expect(subdomain).toEqual("example");
expect(team.subdomain).toEqual("example");
});
});

View File

@ -91,7 +91,7 @@ User.prototype.collectionIds = async function (options = {}) {
const collectionStubs = await Collection.scope({
method: ["withMembership", this.id],
}).findAll({
attributes: ["id", "private"],
attributes: ["id", "permission"],
where: { teamId: this.teamId },
paranoid: true,
...options,
@ -100,7 +100,8 @@ User.prototype.collectionIds = async function (options = {}) {
return collectionStubs
.filter(
(c) =>
!c.private ||
c.permission === "read" ||
c.permission === "read_write" ||
c.memberships.length > 0 ||
c.collectionGroupMemberships.length > 0
)

View File

@ -1,10 +1,75 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { buildUser } from "../test/factories";
// @flow
import { CollectionUser } from "../models";
import { buildUser, buildTeam, buildCollection } from "../test/factories";
import { flushdb } from "../test/support";
beforeEach(() => flushdb());
it("should set JWT secret", async () => {
const user = await buildUser();
expect(user.getJwtToken()).toBeTruthy();
describe("user model", () => {
describe("getJwtToken", () => {
it("should set JWT secret", async () => {
const user = await buildUser();
expect(user.getJwtToken()).toBeTruthy();
});
});
describe("collectionIds", () => {
it("should return read_write collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
});
const response = await user.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
it("should return read collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: "read",
});
const response = await user.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
it("should not return private collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildCollection({
teamId: team.id,
permission: null,
});
const response = await user.collectionIds();
expect(response.length).toEqual(0);
});
it("should not return private collection with membership", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
});
const response = await user.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
});
});

View File

@ -28,7 +28,7 @@ allow(User, "move", Collection, (user, collection) => {
allow(User, ["read", "export"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.private) {
if (!collection.permission) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
@ -51,7 +51,7 @@ allow(User, "share", Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.sharing) return false;
if (collection.private) {
if (collection.permission !== "read_write") {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
@ -73,7 +73,7 @@ allow(User, "share", Collection, (user, collection) => {
allow(User, ["publish", "update"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.private) {
if (collection.permission !== "read_write") {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
@ -95,7 +95,7 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
allow(User, "delete", Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.private) {
if (collection.permission !== "read_write") {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"

View File

@ -0,0 +1,136 @@
// @flow
import { CollectionUser, Collection } from "../models";
import { buildUser, buildTeam, buildCollection } from "../test/factories";
import { flushdb } from "../test/support";
import { serialize } from "./index";
beforeEach(() => flushdb());
describe("read_write permission", () => {
it("should allow read write permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
it("should override read membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
let collection = await buildCollection({
teamId: team.id,
permission: "read_write",
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read",
});
// reload to get membership
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});
describe("read permission", () => {
it("should allow read permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: "read",
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
it("should allow override with read_write membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
let collection = await buildCollection({
teamId: team.id,
permission: "read",
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
});
// reload to get membership
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});
describe("no permission", () => {
it("should allow no permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(false);
expect(abilities.export).toEqual(false);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});
it("should allow override with team member membership permission", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
let collection = await buildCollection({
teamId: team.id,
permission: null,
});
await CollectionUser.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
});
// reload to get membership
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true);
});
});

View File

@ -0,0 +1,83 @@
// @flow
import {
buildUser,
buildTeam,
buildDocument,
buildCollection,
} from "../test/factories";
import { flushdb } from "../test/support";
import { serialize } from "./index";
beforeEach(() => flushdb());
describe("read_write collection", () => {
it("should allow read write permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: "read_write",
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
const abilities = serialize(user, document);
expect(abilities.read).toEqual(true);
expect(abilities.download).toEqual(true);
expect(abilities.update).toEqual(true);
expect(abilities.createChildDocument).toEqual(true);
expect(abilities.archive).toEqual(true);
expect(abilities.delete).toEqual(true);
expect(abilities.share).toEqual(true);
expect(abilities.move).toEqual(true);
});
});
describe("read collection", () => {
it("should allow read only permissions permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: "read",
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
const abilities = serialize(user, document);
expect(abilities.read).toEqual(true);
expect(abilities.download).toEqual(true);
expect(abilities.update).toEqual(false);
expect(abilities.createChildDocument).toEqual(false);
expect(abilities.archive).toEqual(false);
expect(abilities.delete).toEqual(false);
expect(abilities.share).toEqual(false);
expect(abilities.move).toEqual(false);
});
});
describe("private collection", () => {
it("should allow no permissions for team member", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
const abilities = serialize(user, document);
expect(abilities.read).toEqual(false);
expect(abilities.download).toEqual(false);
expect(abilities.update).toEqual(false);
expect(abilities.createChildDocument).toEqual(false);
expect(abilities.archive).toEqual(false);
expect(abilities.delete).toEqual(false);
expect(abilities.share).toEqual(false);
expect(abilities.move).toEqual(false);
});
});

View File

@ -30,7 +30,7 @@ export default function present(collection: Collection) {
icon: collection.icon,
index: collection.index,
color: collection.color || "#4E5C6E",
private: collection.private,
permission: collection.permission,
sharing: collection.sharing,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,

View File

@ -122,7 +122,7 @@ export default class Notifications {
],
});
if (!collection) return;
if (collection.private) return;
if (!collection.permission) return;
const notificationSettings = await NotificationSetting.findAll({
where: {

View File

@ -64,7 +64,7 @@ describe("documents.publish", () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
private: true,
permission: null,
});
const document = await buildDocument({
teamId: user.teamId,

View File

@ -157,9 +157,9 @@ export default class Websockets {
socketio
.to(
collection.private
? `collection-${collection.id}`
: `team-${collection.teamId}`
collection.permission
? `team-${collection.teamId}`
: `collection-${collection.id}`
)
.emit("entities", {
event: event.name,
@ -173,9 +173,9 @@ export default class Websockets {
return socketio
.to(
collection.private
? `collection-${collection.id}`
: `team-${collection.teamId}`
collection.permission
? `team-${collection.teamId}`
: `collection-${collection.id}`
)
.emit("join", {
event: event.name,

View File

@ -159,6 +159,7 @@ export async function buildCollection(overrides: Object = {}) {
name: `Test Collection ${count}`,
description: "Test collection description",
createdById: overrides.userId,
permission: "read_write",
...overrides,
});
}

View File

@ -78,6 +78,7 @@ export const seed = async () => {
urlId: "collection",
teamId: team.id,
createdById: user.id,
permission: "read_write",
});
const document = await Document.create({

View File

@ -86,6 +86,10 @@
"Choose icon": "Choose icon",
"Loading": "Loading",
"Search": "Search",
"Default access": "Default access",
"View and edit": "View and edit",
"View only": "View only",
"No access": "No access",
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?",
"Change Language": "Change Language",
"Dismiss": "Dismiss",
@ -134,8 +138,9 @@
"New document": "New document",
"Import document": "Import document",
"Edit": "Edit",
"Permissions": "Permissions",
"Delete": "Delete",
"Collection members": "Collection members",
"Collection permissions": "Collection permissions",
"Edit collection": "Edit collection",
"Delete collection": "Delete collection",
"Export collection": "Export collection",
@ -199,8 +204,8 @@
"<em>{{ collectionName }}</em> doesnt contain any\n documents yet.": "<em>{{ collectionName }}</em> doesnt contain any\n documents yet.",
"Get started by creating a new one!": "Get started by creating a new one!",
"Create a document": "Create a document",
"Manage members": "Manage members",
"This collection is only visible to people given access": "This collection is only visible to people given access",
"Manage permissions": "Manage permissions",
"This collection is only visible to those given access": "This collection is only visible to those given access",
"Private": "Private",
"Pinned": "Pinned",
"Recently updated": "Recently updated",
@ -211,13 +216,15 @@
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.",
"Name": "Name",
"Alphabetical": "Alphabetical",
"Private collection": "Private collection",
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
"Public document sharing": "Public document sharing",
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
"Saving": "Saving",
"Save": "Save",
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.",
"Creating": "Creating",
"Create": "Create",
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
"Could not add user": "Could not add user",
"Cant find the group youre looking for?": "Cant find the group youre looking for?",
@ -234,16 +241,28 @@
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Read only": "Read only",
"Read & Edit": "Read & Edit",
"Permissions": "Permissions",
"Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in",
"Invited": "Invited",
"Admin": "Admin",
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
"Creating": "Creating",
"Create": "Create",
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
"Could not remove user": "Could not remove user",
"{{ userName }} permissions were updated": "{{ userName }} permissions were updated",
"Could not update user": "Could not update user",
"The {{ groupName }} group was removed from the collection": "The {{ groupName }} group was removed from the collection",
"Could not remove group": "Could not remove group",
"{{ groupName }} permissions were updated": "{{ groupName }} permissions were updated",
"Default access permissions were updated": "Default access permissions were updated",
"Could not update permissions": "Could not update permissions",
"The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.": "The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.",
"Team members can view documents in the <em>{{ collectionName }}</em> collection by default.": "Team members can view documents in the <em>{{ collectionName }}</em> collection by default.",
"Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.": "Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.",
"Additional access": "Additional access",
"Add groups": "Add groups",
"Add people": "Add people",
"Add specific access for individual groups and team members": "Add specific access for individual groups and team members",
"Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}",
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
"Hide contents": "Hide contents",
"Show contents": "Show contents",
"Edit {{noun}}": "Edit {{noun}}",
@ -274,8 +293,6 @@
"Add team members below to give them access to the group. Need to add someone whos not yet on the team yet?": "Add team members below to give them access to the group. Need to add someone whos not yet on the team yet?",
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
"Could not remove user": "Could not remove user",
"Add people": "Add people",
"This group has no members.": "This group has no members.",
"Recently viewed": "Recently viewed",
"Created by me": "Created by me",