From 7e1b07ef98a62c795be5f0b31dafe78e2c8b1da4 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 30 Mar 2021 21:02:08 -0700 Subject: [PATCH] feat: Add read-only collections (#1991) closes #1017 --- app/components/Breadcrumb.js | 11 - app/components/Divider.js | 11 + app/components/GroupListItem.js | 18 +- app/components/InputSelect.js | 2 +- app/components/InputSelectPermission.js | 22 ++ app/components/Labeled.js | 8 +- app/components/List/Item.js | 2 +- app/menus/CollectionMenu.js | 25 +- app/models/Collection.js | 9 +- app/scenes/Collection.js | 42 +-- app/scenes/CollectionEdit.js | 17 -- .../CollectionMembers/CollectionMembers.js | 268 ----------------- app/scenes/CollectionMembers/index.js | 3 - app/scenes/CollectionNew.js | 22 +- .../AddGroupsToCollection.js | 0 .../AddPeopleToCollection.js | 0 .../CollectionGroupMemberListItem.js | 32 +- .../components/MemberListItem.js | 14 +- .../components/UserListItem.js | 2 +- app/scenes/CollectionPermissions/index.js | 283 ++++++++++++++++++ app/scenes/GroupMembers/GroupMembers.js | 2 +- app/stores/CollectionsStore.js | 10 - package.json | 1 + server/api/attachments.test.js | 6 +- server/api/collections.js | 36 ++- server/api/collections.test.js | 74 ++--- server/api/documents.test.js | 49 ++- server/api/revisions.test.js | 2 +- server/api/shares.test.js | 4 +- server/api/views.test.js | 4 +- server/commands/collectionImporter.js | 4 +- .../20210327005406-read-only-collections.js | 38 +++ server/models/Collection.js | 11 +- server/models/Collection.test.js | 2 +- server/models/Document.test.js | 2 +- server/models/Group.test.js | 4 +- server/models/Team.js | 10 +- server/models/Team.test.js | 78 +++-- server/models/User.js | 5 +- server/models/User.test.js | 75 ++++- server/policies/collection.js | 8 +- server/policies/collection.test.js | 136 +++++++++ server/policies/document.test.js | 83 +++++ server/presenters/collection.js | 2 +- server/services/notifications.js | 2 +- server/services/notifications.test.js | 2 +- server/services/websockets.js | 12 +- server/test/factories.js | 1 + server/test/support.js | 1 + shared/i18n/locales/en_US/translation.json | 43 ++- 50 files changed, 940 insertions(+), 558 deletions(-) create mode 100644 app/components/Divider.js create mode 100644 app/components/InputSelectPermission.js delete mode 100644 app/scenes/CollectionMembers/CollectionMembers.js delete mode 100644 app/scenes/CollectionMembers/index.js rename app/scenes/{CollectionMembers => CollectionPermissions}/AddGroupsToCollection.js (100%) rename app/scenes/{CollectionMembers => CollectionPermissions}/AddPeopleToCollection.js (100%) rename app/scenes/{CollectionMembers => CollectionPermissions}/components/CollectionGroupMemberListItem.js (72%) rename app/scenes/{CollectionMembers => CollectionPermissions}/components/MemberListItem.js (89%) rename app/scenes/{CollectionMembers => CollectionPermissions}/components/UserListItem.js (98%) create mode 100644 app/scenes/CollectionPermissions/index.js create mode 100644 server/migrations/20210327005406-read-only-collections.js create mode 100644 server/policies/collection.test.js create mode 100644 server/policies/document.test.js diff --git a/app/components/Breadcrumb.js b/app/components/Breadcrumb.js index c3f38f468..4e7c521c0 100644 --- a/app/components/Breadcrumb.js +++ b/app/components/Breadcrumb.js @@ -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 && ( - <> - {" "} - - )} {collection.name} {path.map((n) => ( @@ -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; diff --git a/app/components/Divider.js b/app/components/Divider.js new file mode 100644 index 000000000..9b4561c0f --- /dev/null +++ b/app/components/Divider.js @@ -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; diff --git a/app/components/GroupListItem.js b/app/components/GroupListItem.js index 7687265e2..481556914 100644 --- a/app/components/GroupListItem.js +++ b/app/components/GroupListItem.js @@ -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 { return ( <> + + + } title={ {group.name} } @@ -84,6 +91,15 @@ class GroupListItem extends React.Component { } } +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; diff --git a/app/components/InputSelect.js b/app/components/InputSelect.js index 2187b9682..83624b2f0 100644 --- a/app/components/InputSelect.js +++ b/app/components/InputSelect.js @@ -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, diff --git a/app/components/InputSelectPermission.js b/app/components/InputSelectPermission.js new file mode 100644 index 000000000..f462408bd --- /dev/null +++ b/app/components/InputSelectPermission.js @@ -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 }> +) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/app/components/Labeled.js b/app/components/Labeled.js index 41b57dec4..58194ef71 100644 --- a/app/components/Labeled.js +++ b/app/components/Labeled.js @@ -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); diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 60da80854..f38df86ae 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -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}; diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index 4c3ad675c..5c0a1fbef 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -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(); - 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 && ( <> setShowCollectionMembers(false)} - isOpen={showCollectionMembers} + title={t("Collection permissions")} + onRequestClose={() => setShowCollectionPermissions(false)} + isOpen={showCollectionPermissions} > - setShowCollectionMembers(false)} - onEdit={() => setShowCollectionEdit(true)} - /> + { @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 { 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 {    - {collection.private && ( - - )} + - - - - + ) : ( @@ -254,10 +228,10 @@ class CollectionScene extends React.Component { {" "} {collection.name}{" "} - {collection.private && ( + {!collection.permission && ( diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js index a759821ad..0d4e3a2cb 100644 --- a/app/scenes/CollectionEdit.js +++ b/app/scenes/CollectionEdit.js @@ -28,7 +28,6 @@ class CollectionEdit extends React.Component { @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 { 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 { 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 { value={`${this.sort.field}.${this.sort.direction}`} onChange={this.handleSortChange} /> - - - - A private collection will only be visible to invited team members. - - void, -}; - -@observer -class CollectionMembers extends React.Component { - @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 ( - - {collection.private ? ( - <> - - Choose which groups and team members have access to view and edit - documents in the private {collection.name}{" "} - collection. You can make this collection visible to the entire - team by{" "} - - changing the visibility - - . - - - - - - ) : ( - - The {collection.name} collection is accessible by - everyone on the team. If you want to limit who can view the - collection,{" "} - make it private - . - - )} - - {collection.private && ( - - Groups - This collection has no groups.} - renderItem={(group) => ( - this.handleRemoveGroup(group)} - onUpdate={(permission) => - this.handleUpdateGroup(group, permission) - } - /> - )} - /> - - - - - )} - {collection.private ? ( - <> - - - - - Individual Members - - ) : ( - Members - )} - ( - this.handleRemoveUser(item)} - onUpdate={(permission) => this.handleUpdateUser(item, permission)} - /> - )} - /> - - - - - ); - } -} - -const GroupsWrap = styled.div` - margin-bottom: 50px; -`; - -export default inject( - "auth", - "users", - "memberships", - "collectionGroupMemberships", - "groups", - "ui" -)(CollectionMembers); diff --git a/app/scenes/CollectionMembers/index.js b/app/scenes/CollectionMembers/index.js deleted file mode 100644 index e1607f75b..000000000 --- a/app/scenes/CollectionMembers/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionMembers from "./CollectionMembers"; -export default CollectionMembers; diff --git a/app/scenes/CollectionNew.js b/app/scenes/CollectionNew.js index 04516eaaf..ae058bd84 100644 --- a/app/scenes/CollectionNew.js +++ b/app/scenes/CollectionNew.js @@ -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 { @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 { 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 { this.hasOpenedIconPicker = true; }; - handlePrivateChange = (ev: SyntheticInputEvent) => { - this.private = ev.target.checked; + handlePermissionChange = (ev: SyntheticInputEvent) => { + this.permission = ev.target.value; }; handleSharingChange = (ev: SyntheticInputEvent) => { @@ -131,15 +132,16 @@ class CollectionNew extends React.Component { icon={this.icon} /> - - 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. {teamSharingEnabled && ( diff --git a/app/scenes/CollectionMembers/AddGroupsToCollection.js b/app/scenes/CollectionPermissions/AddGroupsToCollection.js similarity index 100% rename from app/scenes/CollectionMembers/AddGroupsToCollection.js rename to app/scenes/CollectionPermissions/AddGroupsToCollection.js diff --git a/app/scenes/CollectionMembers/AddPeopleToCollection.js b/app/scenes/CollectionPermissions/AddPeopleToCollection.js similarity index 100% rename from app/scenes/CollectionMembers/AddPeopleToCollection.js rename to app/scenes/CollectionPermissions/AddPeopleToCollection.js diff --git a/app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js b/app/scenes/CollectionPermissions/components/CollectionGroupMemberListItem.js similarity index 72% rename from app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js rename to app/scenes/CollectionPermissions/components/CollectionGroupMemberListItem.js index ce74910b5..a3aecc995 100644 --- a/app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js +++ b/app/scenes/CollectionPermissions/components/CollectionGroupMemberListItem.js @@ -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 }) => ( <>