From 8c02b0028c578ffb23c3831ee4253e5adfe8af63 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 5 Jan 2019 13:37:33 -0800 Subject: [PATCH] Collection Permissions (#829) see https://github.com/outline/outline/issues/668 --- .../DocumentPreview/DocumentPreview.js | 2 + app/components/HelpText.js | 2 +- app/components/Input.js | 9 +- app/components/List/Item.js | 23 +- app/components/List/Placeholder.js | 29 +++ .../LoadingPlaceholder/ListPlaceholder.js | 8 +- .../LoadingPlaceholder/LoadingPlaceholder.js | 5 +- .../components => }/Mask.js | 4 +- .../Sidebar/components/CollectionLink.js | 13 +- .../Sidebar/components/Collections.js | 48 ++-- app/components/Switch.js | 91 +++++++ app/menus/CollectionMenu.js | 30 ++- app/menus/DocumentMenu.js | 22 +- app/menus/NewDocumentMenu.js | 9 +- app/models/BaseModel.js | 6 +- app/models/Collection.js | 52 +++- app/scenes/Collection.js | 136 +++++++---- app/scenes/CollectionEdit.js | 4 +- app/scenes/CollectionNew.js | 17 ++ .../CollectionPermissions.js | 153 ++++++++++++ .../components/MemberListItem.js | 42 ++++ .../components/UserListItem.js | 32 +++ app/scenes/CollectionPermissions/index.js | 3 + app/scenes/Document/components/Breadcrumb.js | 8 +- app/scenes/Search/components/SearchField.js | 1 + .../Settings/components/UserListItem.js | 1 - app/stores/CollectionsStore.js | 10 + app/utils/importFile.js | 3 +- package.json | 4 +- .../__snapshots__/collections.test.js.snap | 25 ++ server/api/apiKeys.js | 2 +- server/api/collections.js | 109 ++++++++- server/api/collections.test.js | 222 +++++++++++++++++- server/api/documents.js | 82 ++++++- server/api/documents.test.js | 99 +++++++- server/api/integrations.js | 2 +- server/api/notificationSettings.js | 4 +- server/api/shares.js | 2 +- server/api/views.js | 4 +- server/middlewares/validation.js | 2 +- .../20181227001547-collection-permissions.js | 53 +++++ server/models/Collection.js | 49 +++- server/models/CollectionUser.js | 34 +++ server/models/Document.js | 10 +- server/models/User.js | 21 +- server/models/index.js | 3 + server/pages/developers/Api.js | 42 +++- server/policies/collection.js | 20 +- server/policies/document.js | 10 +- server/presenters/collection.js | 11 +- server/presenters/document.js | 13 +- server/services/notifications.js | 1 + yarn.lock | 6 +- 53 files changed, 1379 insertions(+), 214 deletions(-) create mode 100644 app/components/List/Placeholder.js rename app/components/{LoadingPlaceholder/components => }/Mask.js (87%) create mode 100644 app/components/Switch.js create mode 100644 app/scenes/CollectionPermissions/CollectionPermissions.js create mode 100644 app/scenes/CollectionPermissions/components/MemberListItem.js create mode 100644 app/scenes/CollectionPermissions/components/UserListItem.js create mode 100644 app/scenes/CollectionPermissions/index.js create mode 100644 server/migrations/20181227001547-collection-permissions.js create mode 100644 server/models/CollectionUser.js diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 11661a3a..d595c772 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -84,6 +84,8 @@ const Heading = styled.h3` margin-bottom: 0.25em; overflow: hidden; white-space: nowrap; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; `; const Actions = styled(Flex)` diff --git a/app/components/HelpText.js b/app/components/HelpText.js index 9a0df7f6..afacf1ee 100644 --- a/app/components/HelpText.js +++ b/app/components/HelpText.js @@ -4,7 +4,7 @@ import styled from 'styled-components'; const HelpText = styled.p` margin-top: 0; color: ${props => props.theme.slateDark}; - font-size: ${props => (props.small ? '13px' : 'auto')}; + font-size: ${props => (props.small ? '13px' : 'inherit')}; `; export default HelpText; diff --git a/app/components/Input.js b/app/components/Input.js index a414c7c7..b71f77b3 100644 --- a/app/components/Input.js +++ b/app/components/Input.js @@ -27,6 +27,10 @@ const RealInput = styled.input` &::placeholder { color: ${props => props.theme.slate}; } + + &::-webkit-search-cancel-button { + -webkit-appearance: searchfield-cancel-button; + } `; const Wrapper = styled.div` @@ -78,7 +82,10 @@ export default function Input({ diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 6b403670..cd275cd8 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -1,21 +1,24 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; +import Flex from 'shared/components/Flex'; type Props = { image?: React.Node, title: string, - subtitle: React.Node, + subtitle?: React.Node, actions?: React.Node, }; const ListItem = ({ image, title, subtitle, actions }: Props) => { + const compact = !subtitle; + return ( - + {image && {image}} - + {title} - {subtitle} + {subtitle && {subtitle}} {actions && {actions}} @@ -24,22 +27,26 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => { const Wrapper = styled.li` display: flex; - padding: 12px 0; + padding: ${props => (props.compact ? '8px' : '12px')} 0; margin: 0; border-bottom: 1px solid ${props => props.theme.smokeDark}; `; -const Image = styled.div` +const Image = styled(Flex)` padding: 0 8px 0 0; max-height: 40px; + align-items: center; + user-select: none; `; -const Heading = styled.h2` +const Heading = styled.p` font-size: 16px; + font-weight: 500; + line-height: 1.2; margin: 0; `; -const Content = styled.div` +const Content = styled(Flex)` flex-grow: 1; `; diff --git a/app/components/List/Placeholder.js b/app/components/List/Placeholder.js new file mode 100644 index 00000000..51c6f3de --- /dev/null +++ b/app/components/List/Placeholder.js @@ -0,0 +1,29 @@ +// @flow +import * as React from 'react'; +import { times } from 'lodash'; +import styled from 'styled-components'; +import Mask from 'components/Mask'; +import Fade from 'components/Fade'; +import Flex from 'shared/components/Flex'; + +type Props = { + count?: number, +}; + +const Placeholder = ({ count }: Props) => { + return ( + + {times(count || 2, index => ( + + + + ))} + + ); +}; + +const Item = styled(Flex)` + padding: 15px 0 16px; +`; + +export default Placeholder; diff --git a/app/components/LoadingPlaceholder/ListPlaceholder.js b/app/components/LoadingPlaceholder/ListPlaceholder.js index 017a1ce2..aba0e53d 100644 --- a/app/components/LoadingPlaceholder/ListPlaceholder.js +++ b/app/components/LoadingPlaceholder/ListPlaceholder.js @@ -1,8 +1,8 @@ // @flow import * as React from 'react'; -import _ from 'lodash'; +import { times } from 'lodash'; import styled from 'styled-components'; -import Mask from './components/Mask'; +import Mask from 'components/Mask'; import Fade from 'components/Fade'; import Flex from 'shared/components/Flex'; @@ -13,7 +13,7 @@ type Props = { const ListPlaceHolder = ({ count }: Props) => { return ( - {_.times(count || 2, index => ( + {times(count || 2, index => ( @@ -24,7 +24,7 @@ const ListPlaceHolder = ({ count }: Props) => { }; const Item = styled(Flex)` - padding: 18px 0; + padding: 10px 0; `; export default ListPlaceHolder; diff --git a/app/components/LoadingPlaceholder/LoadingPlaceholder.js b/app/components/LoadingPlaceholder/LoadingPlaceholder.js index 9f27b12a..15de0f56 100644 --- a/app/components/LoadingPlaceholder/LoadingPlaceholder.js +++ b/app/components/LoadingPlaceholder/LoadingPlaceholder.js @@ -1,6 +1,6 @@ // @flow import * as React from 'react'; -import Mask from './components/Mask'; +import Mask from 'components/Mask'; import Fade from 'components/Fade'; import Flex from 'shared/components/Flex'; @@ -8,7 +8,8 @@ export default function LoadingPlaceholder(props: Object) { return ( - + +
diff --git a/app/components/LoadingPlaceholder/components/Mask.js b/app/components/Mask.js similarity index 87% rename from app/components/LoadingPlaceholder/components/Mask.js rename to app/components/Mask.js index d2978634..32ecd667 100644 --- a/app/components/LoadingPlaceholder/components/Mask.js +++ b/app/components/Mask.js @@ -23,8 +23,8 @@ class Mask extends React.Component<*> { const Redacted = styled(Flex)` width: ${props => (props.header ? props.width / 2 : props.width)}%; - height: ${props => (props.header ? 28 : 18)}px; - margin-bottom: ${props => (props.header ? 18 : 12)}px; + height: ${props => (props.height ? props.height : props.header ? 24 : 18)}px; + margin-bottom: 6px; background-color: ${props => props.theme.smokeDark}; animation: ${pulsate} 1.3s infinite; diff --git a/app/components/Sidebar/components/CollectionLink.js b/app/components/Sidebar/components/CollectionLink.js index 2c504d70..dbdb7335 100644 --- a/app/components/Sidebar/components/CollectionLink.js +++ b/app/components/Sidebar/components/CollectionLink.js @@ -2,7 +2,7 @@ import * as React from 'react'; import { observer } from 'mobx-react'; import { observable } from 'mobx'; -import { CollectionIcon } from 'outline-icons'; +import { CollectionIcon, PrivateCollectionIcon } from 'outline-icons'; import styled from 'styled-components'; import Collection from 'models/Collection'; import Document from 'models/Document'; @@ -45,7 +45,16 @@ class CollectionLink extends React.Component { } + icon={ + collection.private ? ( + + ) : ( + + ) + } iconColor={collection.color} expand={expanded} hideExpandToggle diff --git a/app/components/Sidebar/components/Collections.js b/app/components/Sidebar/components/Collections.js index 3e1d31e4..ffc04331 100644 --- a/app/components/Sidebar/components/Collections.js +++ b/app/components/Sidebar/components/Collections.js @@ -8,6 +8,7 @@ import { PlusIcon } from 'outline-icons'; import Header from './Header'; import SidebarLink from './SidebarLink'; import CollectionLink from './CollectionLink'; +import Fade from 'components/Fade'; import CollectionsStore from 'stores/CollectionsStore'; import UiStore from 'stores/UiStore'; @@ -32,29 +33,30 @@ class Collections extends React.Component { const { history, location, collections, ui, documents } = this.props; return ( - -
Collections
- {collections.orderedData.map(collection => ( - - ))} - - {collections.isLoaded && ( - } - > - New collection… - - )} -
+ collections.isLoaded && ( + + +
Collections
+ {collections.orderedData.map(collection => ( + + ))} + } + > + New collection… + +
+
+ ) ); } } diff --git a/app/components/Switch.js b/app/components/Switch.js new file mode 100644 index 00000000..3992ee91 --- /dev/null +++ b/app/components/Switch.js @@ -0,0 +1,91 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { LabelText } from 'components/Input'; + +type Props = { + width?: number, + height?: number, + label?: string, + id?: string, +}; + +function Switch({ width = 38, height = 20, label, ...props }: Props) { + const component = ( + + + + + ); + + if (label) { + return ( + + ); + } + + return component; +} + +const Label = styled.label` + display: flex; + align-items: center; +`; + +const Wrapper = styled.label` + position: relative; + display: inline-block; + width: ${props => props.width}px; + height: ${props => props.height}px; + margin-bottom: 4px; +`; + +const Slider = styled.span` + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: ${props => props.theme.slate}; + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: ${props => props.height}px; + + &:before { + position: absolute; + content: ''; + height: ${props => props.height - 8}px; + width: ${props => props.height - 8}px; + left: 4px; + bottom: 4px; + background-color: white; + border-radius: 50%; + -webkit-transition: 0.4s; + transition: 0.4s; + } +`; + +const HiddenInput = styled.input` + opacity: 0; + width: 0; + height: 0; + visibility: hidden; + + &:checked + ${Slider} { + background-color: ${props => props.theme.primary}; + } + + &:focus + ${Slider} { + box-shadow: 0 0 1px ${props => props.theme.primary}; + } + + &:checked + ${Slider}:before { + transform: translateX(${props => props.width - props.height}px); + } +`; + +export default Switch; diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index 707dea4d..bf6bf1ec 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -1,8 +1,11 @@ // @flow import * as React from 'react'; +import { observable } from 'mobx'; import { inject, observer } from 'mobx-react'; import styled from 'styled-components'; import { MoreIcon } from 'outline-icons'; +import Modal from 'components/Modal'; +import CollectionPermissions from 'scenes/CollectionPermissions'; import getDataTransferFiles from 'utils/getDataTransferFiles'; import importFile from 'utils/importFile'; @@ -24,6 +27,7 @@ type Props = { @observer class CollectionMenu extends React.Component { file: ?HTMLInputElement; + @observable permissionsModalOpen: boolean = false; onNewDocument = (ev: SyntheticEvent<*>) => { ev.preventDefault(); @@ -71,17 +75,36 @@ class CollectionMenu extends React.Component { this.props.ui.setActiveModal('collection-export', { collection }); }; + onPermissions = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.permissionsModalOpen = true; + }; + + handlePermissionsModalClose = () => { + this.permissionsModalOpen = false; + }; + render() { const { collection, label, onOpen, onClose } = this.props; return ( - + (this.file = ref)} onChange={this.onFilePicked} accept="text/markdown, text/plain" /> + + + } onOpen={onOpen} @@ -97,6 +120,9 @@ class CollectionMenu extends React.Component {
Edit… + + Permissions… + Export… @@ -104,7 +130,7 @@ class CollectionMenu extends React.Component { )} Delete…
-
+ ); } } diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 6bb84e34..5f12ff50 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -76,14 +76,7 @@ class DocumentMenu extends React.Component { }; render() { - const { - document, - label, - className, - showPrint, - showToggleEmbeds, - auth, - } = this.props; + const { document, label, className, showPrint, auth } = this.props; const canShareDocuments = auth.team && auth.team.sharing; return ( @@ -114,19 +107,6 @@ class DocumentMenu extends React.Component { Share link… )} - {showToggleEmbeds && ( - - {document.embedsDisabled ? ( - - Enable embeds - - ) : ( - - Disable embeds - - )} - - )}
Document history diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js index 22c607ba..5d5a64c2 100644 --- a/app/menus/NewDocumentMenu.js +++ b/app/menus/NewDocumentMenu.js @@ -2,7 +2,7 @@ import * as React from 'react'; import { withRouter } from 'react-router-dom'; import { inject } from 'mobx-react'; -import { MoreIcon, CollectionIcon } from 'outline-icons'; +import { MoreIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons'; import { newDocumentUrl } from 'utils/routeHelpers'; import CollectionsStore from 'stores/CollectionsStore'; @@ -42,7 +42,12 @@ class NewDocumentMenu extends React.Component { key={collection.id} onClick={() => this.handleNewDocument(collection)} > - {collection.name} + {collection.private ? ( + + ) : ( + + )}{' '} + {collection.name} ))} diff --git a/app/models/BaseModel.js b/app/models/BaseModel.js index cd2dcbd2..4a10d026 100644 --- a/app/models/BaseModel.js +++ b/app/models/BaseModel.js @@ -17,11 +17,11 @@ export default class BaseModel { try { // ensure that the id is passed if the document has one if (params) params = { ...params, id: this.id }; - await this.store.save(params || this.toJS()); + const model = await this.store.save(params || this.toJS()); // if saving is successful set the new values on the model itself - if (params) set(this, params); - return this; + set(this, { ...params, ...model }); + return model; } finally { this.isSaving = false; } diff --git a/app/models/Collection.js b/app/models/Collection.js index f27e10bd..fecc37d1 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -1,18 +1,23 @@ // @flow -import { pick } from 'lodash'; -import { action, computed } from 'mobx'; +import invariant from 'invariant'; +import { map, without, pick, filter } from 'lodash'; +import { action, computed, observable } from 'mobx'; import BaseModel from 'models/BaseModel'; import Document from 'models/Document'; +import User from 'models/User'; import { client } from 'utils/ApiClient'; import type { NavigationNode } from 'types'; export default class Collection extends BaseModel { - isSaving: boolean; + @observable isSaving: boolean; + @observable isLoadingUsers: boolean; + @observable userIds: string[] = []; id: string; name: string; description: string; color: string; + private: boolean; type: 'atlas' | 'journal'; documents: NavigationNode[]; createdAt: ?string; @@ -37,6 +42,45 @@ export default class Collection extends BaseModel { return results; } + @computed + get users(): User[] { + return filter(this.store.rootStore.users.active, user => + this.userIds.includes(user.id) + ); + } + + @action + async fetchUsers() { + this.isLoadingUsers = true; + + try { + const res = await client.post('/collections.users', { id: this.id }); + invariant(res && res.data, 'User data should be available'); + this.userIds = map(res.data, user => user.id); + res.data.forEach(this.store.rootStore.users.add); + } finally { + this.isLoadingUsers = false; + } + } + + @action + async addUser(user: User) { + await client.post('/collections.add_user', { + id: this.id, + userId: user.id, + }); + this.userIds = this.userIds.concat(user.id); + } + + @action + async removeUser(user: User) { + await client.post('/collections.remove_user', { + id: this.id, + userId: user.id, + }); + this.userIds = without(this.userIds, user.id); + } + @action updateDocument(document: Document) { const travelDocuments = (documentList, path) => @@ -53,7 +97,7 @@ export default class Collection extends BaseModel { } toJS = () => { - return pick(this, ['name', 'color', 'description']); + return pick(this, ['id', 'name', 'color', 'description', 'private']); }; export = () => { diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index d9dd7b87..7b86e9ae 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -4,7 +4,12 @@ import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import { withRouter, Link } from 'react-router-dom'; import styled from 'styled-components'; -import { CollectionIcon, NewDocumentIcon, PinIcon } from 'outline-icons'; +import { + CollectionIcon, + PrivateCollectionIcon, + NewDocumentIcon, + PinIcon, +} from 'outline-icons'; import RichMarkdownEditor from 'rich-markdown-editor'; import { newDocumentUrl } from 'utils/routeHelpers'; @@ -19,12 +24,15 @@ import Actions, { Action, Separator } from 'components/Actions'; import Heading from 'components/Heading'; import CenteredContent from 'components/CenteredContent'; import { ListPlaceholder } from 'components/LoadingPlaceholder'; +import Mask from 'components/Mask'; import Button from 'components/Button'; import HelpText from 'components/HelpText'; import DocumentList from 'components/DocumentList'; import Subheading from 'components/Subheading'; import PageTitle from 'components/PageTitle'; import Flex from 'shared/components/Flex'; +import Modal from 'components/Modal'; +import CollectionPermissions from 'scenes/CollectionPermissions'; type Props = { ui: UiStore, @@ -38,6 +46,7 @@ type Props = { class CollectionScene extends React.Component { @observable collection: ?Collection; @observable isFetching: boolean = true; + @observable permissionsModalOpen: boolean = false; componentDidMount() { this.loadContent(this.props.match.params.id); @@ -82,6 +91,15 @@ class CollectionScene extends React.Component { } }; + onPermissions = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.permissionsModalOpen = true; + }; + + handlePermissionsModalClose = () => { + this.permissionsModalOpen = false; + }; + renderActions() { return ( @@ -101,29 +119,6 @@ class CollectionScene extends React.Component { ); } - renderEmptyCollection() { - if (!this.collection) return null; - - return ( - - - - {' '} - {this.collection.name} - - - Publish your first document to start building this collection. - - - - - - - {this.renderActions()} - - ); - } - renderNotFound() { return ; } @@ -132,9 +127,6 @@ class CollectionScene extends React.Component { if (!this.isFetching && !this.collection) { return this.renderNotFound(); } - if (this.collection && this.collection.isEmpty) { - return this.renderEmptyCollection(); - } const pinnedDocuments = this.collection ? this.props.documents.pinnedInCollection(this.collection.id) @@ -143,43 +135,85 @@ class CollectionScene extends React.Component { ? this.props.documents.recentlyUpdatedInCollection(this.collection.id) : []; const hasPinnedDocuments = !!pinnedDocuments.length; + const collection = this.collection; return ( - {this.collection ? ( + {collection ? ( - + - {' '} - {this.collection.name} + {collection.private ? ( + + ) : ( + + )}{' '} + {collection.name} - {this.collection.description && ( - - )} - - {hasPinnedDocuments && ( + {collection.isEmpty ? ( - - Pinned - - + + Collections are for grouping your knowledge base. Get started + by creating a new document. + + + + +    + {collection.private && ( + + )} + + + + + + ) : ( + + {collection.description && ( + + )} + + {hasPinnedDocuments && ( + + + Pinned + + + + )} + + Recently edited + )} - Recently edited - {this.renderActions()} ) : ( - + + + + + + )} ); diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js index ab6cf953..9287725b 100644 --- a/app/scenes/CollectionEdit.js +++ b/app/scenes/CollectionEdit.js @@ -66,8 +66,8 @@ class CollectionEdit extends React.Component {
- You can edit a collection’s details at any time, however doing so - often might confuse your team mates. + You can edit a collection’s name and other details at any time, + however doing so often might confuse your team mates. { @observable name: string = ''; @observable description: string = ''; @observable color: string = ''; + @observable private: boolean = false; @observable isSaving: boolean; handleSubmit = async (ev: SyntheticEvent<*>) => { @@ -35,6 +37,7 @@ class CollectionNew extends React.Component { name: this.name, description: this.description, color: this.color, + private: this.private, }, this.props.collections ); @@ -58,6 +61,10 @@ class CollectionNew extends React.Component { this.description = getValue(); }; + handlePrivateChange = (ev: SyntheticInputEvent<*>) => { + this.private = ev.target.checked; + }; + handleColor = (color: string) => { this.color = color; }; @@ -87,6 +94,16 @@ class CollectionNew extends React.Component { maxHeight={200} /> + + + A private collection will only be visible to invited team members. + + diff --git a/app/scenes/CollectionPermissions/CollectionPermissions.js b/app/scenes/CollectionPermissions/CollectionPermissions.js new file mode 100644 index 00000000..29eec2cc --- /dev/null +++ b/app/scenes/CollectionPermissions/CollectionPermissions.js @@ -0,0 +1,153 @@ +// @flow +import * as React from 'react'; +import { reject } from 'lodash'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; +import Flex from 'shared/components/Flex'; +import Fade from 'components/Fade'; +import Input from 'components/Input'; +import HelpText from 'components/HelpText'; +import Subheading from 'components/Subheading'; +import List from 'components/List'; +import Placeholder from 'components/List/Placeholder'; +import Switch from 'components/Switch'; +import UserListItem from './components/UserListItem'; +import MemberListItem from './components/MemberListItem'; +import Collection from 'models/Collection'; +import UsersStore from 'stores/UsersStore'; +import AuthStore from 'stores/AuthStore'; +import UiStore from 'stores/UiStore'; + +type Props = { + users: UsersStore, + ui: UiStore, + auth: AuthStore, + collection: Collection, +}; + +@observer +class CollectionPermissions extends React.Component { + @observable isSaving: boolean; + @observable filter: string; + + componentDidMount() { + this.props.users.fetchPage(); + this.props.collection.fetchUsers(); + } + + handlePrivateChange = async (ev: SyntheticInputEvent<*>) => { + const { collection } = this.props; + + try { + collection.private = ev.target.checked; + await collection.save(); + + if (collection.private) { + await collection.fetchUsers(); + } + } catch (err) { + collection.private = !ev.target.checked; + this.props.ui.showToast('Collection privacy could not be changed'); + } + }; + + handleAddUser = user => { + try { + this.props.collection.addUser(user); + } catch (err) { + this.props.ui.showToast('Could not add user'); + } + }; + + handleRemoveUser = user => { + try { + this.props.collection.removeUser(user); + } catch (err) { + this.props.ui.showToast('Could not remove user'); + } + }; + + handleFilter = (ev: SyntheticInputEvent<*>) => { + this.filter = ev.target.value.toLowerCase(); + }; + + render() { + const { collection, users, auth } = this.props; + const { user } = auth; + if (!user) return null; + + const otherUsers = reject(users.active, user => + collection.userIds.includes(user.id) + ); + const hasOtherUsers = !!otherUsers.length; + const isFirstLoadingUsers = + collection.isLoadingUsers && !collection.users.length; + const filteredUsers = reject( + otherUsers, + user => this.filter && !user.name.toLowerCase().includes(this.filter) + ); + + return ( + + + Choose which people on the team have access to read and edit documents + in the {collection.name} collection. By default + collections are visible to all team members. + + + + + {collection.private && ( + + + Invited ({collection.users.length}) + + {isFirstLoadingUsers ? ( + + ) : ( + collection.users.map(member => ( + this.handleRemoveUser(member)} + /> + )) + )} + + + {hasOtherUsers && ( + + Team Members + + + {filteredUsers.map(member => ( + this.handleAddUser(member)} + showAdd + /> + ))} + + + )} + + + )} + + ); + } +} + +export default inject('auth', 'ui', 'users')(CollectionPermissions); diff --git a/app/scenes/CollectionPermissions/components/MemberListItem.js b/app/scenes/CollectionPermissions/components/MemberListItem.js new file mode 100644 index 00000000..4a844762 --- /dev/null +++ b/app/scenes/CollectionPermissions/components/MemberListItem.js @@ -0,0 +1,42 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { MoreIcon } from 'outline-icons'; +import Avatar from 'components/Avatar'; +import HelpText from 'components/HelpText'; +import Flex from 'shared/components/Flex'; +import ListItem from 'components/List/Item'; +import User from 'models/User'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +type Props = { + user: User, + showRemove: boolean, + onRemove: () => *, +}; + +const MemberListItem = ({ user, onRemove, showRemove }: Props) => { + return ( + } + actions={ + + Can edit  + {showRemove && ( + }> + Remove + + )} + + } + /> + ); +}; + +const Permission = styled(HelpText)` + text-transform: uppercase; + font-size: 11px; +`; + +export default MemberListItem; diff --git a/app/scenes/CollectionPermissions/components/UserListItem.js b/app/scenes/CollectionPermissions/components/UserListItem.js new file mode 100644 index 00000000..3c74209a --- /dev/null +++ b/app/scenes/CollectionPermissions/components/UserListItem.js @@ -0,0 +1,32 @@ +// @flow +import * as React from 'react'; +import Avatar from 'components/Avatar'; +import Button from 'components/Button'; +import ListItem from 'components/List/Item'; +import User from 'models/User'; + +type Props = { + user: User, + showAdd: boolean, + onAdd: () => *, +}; + +const UserListItem = ({ user, onAdd, showAdd }: Props) => { + return ( + } + actions={ + showAdd ? ( + + ) : ( + undefined + ) + } + /> + ); +}; + +export default UserListItem; diff --git a/app/scenes/CollectionPermissions/index.js b/app/scenes/CollectionPermissions/index.js new file mode 100644 index 00000000..e35a565a --- /dev/null +++ b/app/scenes/CollectionPermissions/index.js @@ -0,0 +1,3 @@ +// @flow +import CollectionPermissions from './CollectionPermissions'; +export default CollectionPermissions; diff --git a/app/scenes/Document/components/Breadcrumb.js b/app/scenes/Document/components/Breadcrumb.js index 461b5b11..ca680a9c 100644 --- a/app/scenes/Document/components/Breadcrumb.js +++ b/app/scenes/Document/components/Breadcrumb.js @@ -4,7 +4,7 @@ import { observer, inject } from 'mobx-react'; import breakpoint from 'styled-components-breakpoint'; import styled from 'styled-components'; import { Link } from 'react-router-dom'; -import { CollectionIcon, GoToIcon } from 'outline-icons'; +import { CollectionIcon, PrivateCollectionIcon, GoToIcon } from 'outline-icons'; import Document from 'models/Document'; import CollectionsStore from 'stores/CollectionsStore'; @@ -26,7 +26,11 @@ const Breadcrumb = observer(({ document, collections }: Props) => { return ( - {' '} + {collection.private ? ( + + ) : ( + + )}{' '} {collection.name} {path.map(n => ( diff --git a/app/scenes/Search/components/SearchField.js b/app/scenes/Search/components/SearchField.js index 07d67016..5d6d7e3c 100644 --- a/app/scenes/Search/components/SearchField.js +++ b/app/scenes/Search/components/SearchField.js @@ -35,6 +35,7 @@ class SearchField extends React.Component { onChange={this.handleChange} spellCheck="false" placeholder="search…" + type="search" autoFocus /> diff --git a/app/scenes/Settings/components/UserListItem.js b/app/scenes/Settings/components/UserListItem.js index 7741f44d..18ee715c 100644 --- a/app/scenes/Settings/components/UserListItem.js +++ b/app/scenes/Settings/components/UserListItem.js @@ -16,7 +16,6 @@ type Props = { const UserListItem = ({ user, showMenu }: Props) => { return ( } subtitle={ diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index f0ffb99d..ebd047bf 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -36,6 +36,16 @@ export default class CollectionsStore extends BaseStore { return naturalSort(Array.from(this.data.values()), 'name'); } + @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 */ diff --git a/app/utils/importFile.js b/app/utils/importFile.js index 08b7a40e..ebd98151 100644 --- a/app/utils/importFile.js +++ b/app/utils/importFile.js @@ -1,10 +1,9 @@ // @flow import Document from '../models/Document'; -import DocumentsStore from '../stores/DocumentsStore'; type Options = { file: File, - documents: DocumentsStore, + documents: *, collectionId: string, documentId?: string, }; diff --git a/package.json b/package.json index fdfef3f7..f7ac91bb 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ ] }, "engines": { - "node": "8.11" + "node": ">=8.10" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "nodemailer": "^4.4.0", "normalize.css": "^7.0.0", "normalizr": "2.0.1", - "outline-icons": "^1.5.0", + "outline-icons": "^1.6.0", "oy-vey": "^0.10.0", "parse-domain": "2.1.6", "pg": "^6.1.5", diff --git a/server/api/__snapshots__/collections.test.js.snap b/server/api/__snapshots__/collections.test.js.snap index 92266fad..990719b3 100644 --- a/server/api/__snapshots__/collections.test.js.snap +++ b/server/api/__snapshots__/collections.test.js.snap @@ -1,5 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`#collections.add_user should require user in team 1`] = ` +Object { + "error": "authorization_error", + "message": "Authorization error", + "ok": false, +} +`; + exports[`#collections.create should require authentication 1`] = ` Object { "error": "authentication_required", @@ -53,3 +61,20 @@ Object { "status": 401, } `; + +exports[`#collections.remove_user should require user in team 1`] = ` +Object { + "error": "authorization_error", + "message": "Authorization error", + "ok": false, +} +`; + +exports[`#collections.users should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/api/apiKeys.js b/server/api/apiKeys.js index 3086300f..3dd1b1b7 100644 --- a/server/api/apiKeys.js +++ b/server/api/apiKeys.js @@ -48,7 +48,7 @@ router.post('apiKeys.list', auth(), pagination(), async ctx => { router.post('apiKeys.delete', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const user = ctx.state.user; const key = await ApiKey.findById(id); diff --git a/server/api/collections.js b/server/api/collections.js index 9812f070..5c453010 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -1,11 +1,10 @@ // @flow import Router from 'koa-router'; - import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; -import { presentCollection } from '../presenters'; -import { Collection, Team } from '../models'; -import { ValidationError } from '../errors'; +import { presentCollection, presentUser } from '../presenters'; +import { Collection, CollectionUser, Team, User } from '../models'; +import { ValidationError, InvalidRequestError } from '../errors'; import { exportCollection, exportCollections } from '../logistics'; import policy from '../policies'; @@ -14,6 +13,8 @@ const router = new Router(); router.post('collections.create', auth(), async ctx => { const { name, color, description, type } = ctx.body; + const isPrivate = ctx.body.private; + ctx.assertPresent(name, 'name is required'); if (color) ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)'); @@ -28,6 +29,7 @@ router.post('collections.create', auth(), async ctx => { type: type || 'atlas', teamId: user.teamId, creatorId: user.id, + private: isPrivate, }); ctx.body = { @@ -37,9 +39,9 @@ router.post('collections.create', auth(), async ctx => { router.post('collections.info', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); - const collection = await Collection.scope('withRecentDocuments').findById(id); + const collection = await Collection.findById(id); authorize(ctx.state.user, 'read', collection); ctx.body = { @@ -47,9 +49,76 @@ router.post('collections.info', auth(), async ctx => { }; }); +router.post('collections.add_user', auth(), async ctx => { + const { id, userId, permission = 'read_write' } = ctx.body; + ctx.assertUuid(id, 'id is required'); + ctx.assertUuid(userId, 'userId is required'); + + const collection = await Collection.findById(id); + authorize(ctx.state.user, 'update', collection); + + if (!collection.private) { + throw new InvalidRequestError('Collection must be private to add users'); + } + + const user = await User.findById(userId); + authorize(ctx.state.user, 'read', user); + + await CollectionUser.create({ + collectionId: id, + userId, + permission, + createdById: ctx.state.user.id, + }); + + ctx.body = { + success: true, + }; +}); + +router.post('collections.remove_user', auth(), async ctx => { + const { id, userId } = ctx.body; + ctx.assertUuid(id, 'id is required'); + ctx.assertUuid(userId, 'userId is required'); + + const collection = await Collection.findById(id); + authorize(ctx.state.user, 'update', collection); + + if (!collection.private) { + throw new InvalidRequestError('Collection must be private to remove users'); + } + + const user = await User.findById(userId); + authorize(ctx.state.user, 'read', user); + + await collection.removeUser(user); + + ctx.body = { + success: true, + }; +}); + +router.post('collections.users', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertUuid(id, 'id is required'); + + const collection = await Collection.findById(id); + authorize(ctx.state.user, 'read', collection); + + const users = await collection.getUsers(); + + const data = await Promise.all( + users.map(async user => await presentUser(ctx, user)) + ); + + ctx.body = { + data, + }; +}); + router.post('collections.export', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const user = ctx.state.user; const collection = await Collection.findById(id); @@ -78,16 +147,33 @@ router.post('collections.exportAll', auth(), async ctx => { router.post('collections.update', auth(), async ctx => { const { id, name, description, color } = ctx.body; + const isPrivate = ctx.body.private; + ctx.assertPresent(name, 'name is required'); if (color) ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)'); + const user = ctx.state.user; const collection = await Collection.findById(id); - authorize(ctx.state.user, 'update', collection); + authorize(user, 'update', collection); + + if (isPrivate && !collection.private) { + await CollectionUser.findOrCreate({ + where: { + collectionId: collection.id, + userId: user.id, + }, + defaults: { + permission: 'read_write', + createdById: user.id, + }, + }); + } collection.name = name; collection.description = description; collection.color = color; + collection.private = isPrivate; await collection.save(); ctx.body = { @@ -97,9 +183,12 @@ router.post('collections.update', auth(), async ctx => { router.post('collections.list', auth(), pagination(), async ctx => { const user = ctx.state.user; - const collections = await Collection.findAll({ + + const collectionIds = await user.collectionIds(); + let collections = await Collection.findAll({ where: { teamId: user.teamId, + id: collectionIds, }, order: [['updatedAt', 'DESC']], offset: ctx.state.pagination.offset, @@ -120,7 +209,7 @@ router.post('collections.list', auth(), pagination(), async ctx => { router.post('collections.delete', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const collection = await Collection.findById(id); authorize(ctx.state.user, 'delete', collection); diff --git a/server/api/collections.test.js b/server/api/collections.test.js index 70d25f2b..6aa10432 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -2,7 +2,7 @@ import TestServer from 'fetch-test-server'; import app from '..'; import { flushdb, seed } from '../test/support'; -import { buildUser } from '../test/factories'; +import { buildUser, buildCollection } from '../test/factories'; import { Collection } from '../models'; const server = new TestServer(app.callback()); @@ -29,9 +29,54 @@ describe('#collections.list', async () => { expect(body.data.length).toEqual(1); expect(body.data[0].id).toEqual(collection.id); }); + + it('should not return private collections not a member of', async () => { + const { user, collection } = await seed(); + await buildCollection({ + private: true, + teamId: user.teamId, + }); + const res = await server.post('/api/collections.list', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(collection.id); + }); + + it('should return private collections member of', async () => { + const { user } = await seed(); + await buildCollection({ + private: true, + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post('/api/collections.list', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(2); + }); }); describe('#collections.export', async () => { + it('should require user to be a member', async () => { + const { user } = await seed(); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + const res = await server.post('/api/collections.export', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + + expect(res.status).toEqual(403); + }); + it('should require authentication', async () => { const res = await server.post('/api/collections.export'); const body = await res.json(); @@ -77,6 +122,170 @@ describe('#collections.exportAll', async () => { }); }); +describe('#collections.add_user', async () => { + it('should add user to collection', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + private: true, + }); + const anotherUser = await buildUser({ teamId: user.teamId }); + const res = await server.post('/api/collections.add_user', { + body: { + token: user.getJwtToken(), + id: collection.id, + userId: anotherUser.id, + }, + }); + + const users = await collection.getUsers(); + expect(res.status).toEqual(200); + expect(users.length).toEqual(2); + }); + + it('should require user in team', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + private: true, + }); + const anotherUser = await buildUser(); + const res = await server.post('/api/collections.add_user', { + body: { + token: user.getJwtToken(), + id: collection.id, + userId: anotherUser.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/collections.add_user'); + + expect(res.status).toEqual(401); + }); + + it('should require authorization', async () => { + const { collection } = await seed(); + const user = await buildUser(); + const anotherUser = await buildUser({ teamId: user.teamId }); + + const res = await server.post('/api/collections.add_user', { + body: { + token: user.getJwtToken(), + id: collection.id, + userId: anotherUser.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); + +describe('#collections.remove_user', async () => { + it('should remove user from collection', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + private: true, + }); + const anotherUser = await buildUser({ teamId: user.teamId }); + + await server.post('/api/collections.add_user', { + body: { + token: user.getJwtToken(), + id: collection.id, + userId: anotherUser.id, + }, + }); + + const res = await server.post('/api/collections.remove_user', { + body: { + token: user.getJwtToken(), + id: collection.id, + userId: anotherUser.id, + }, + }); + + const users = await collection.getUsers(); + expect(res.status).toEqual(200); + expect(users.length).toEqual(1); + }); + + it('should require user in team', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + private: true, + }); + const anotherUser = await buildUser(); + const res = await server.post('/api/collections.remove_user', { + body: { + token: user.getJwtToken(), + id: collection.id, + userId: anotherUser.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/collections.remove_user'); + + expect(res.status).toEqual(401); + }); + + it('should require authorization', async () => { + const { collection } = await seed(); + const user = await buildUser(); + const anotherUser = await buildUser({ teamId: user.teamId }); + + const res = await server.post('/api/collections.remove_user', { + body: { + token: user.getJwtToken(), + id: collection.id, + userId: anotherUser.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); + +describe('#collections.users', async () => { + it('should return members in private collection', async () => { + const { collection, user } = await seed(); + const res = await server.post('/api/collections.users', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + expect(res.status).toEqual(200); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/collections.users'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const { collection } = await seed(); + const user = await buildUser(); + const res = await server.post('/api/collections.users', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + expect(res.status).toEqual(403); + }); +}); + describe('#collections.info', async () => { it('should return collection', async () => { const { user, collection } = await seed(); @@ -89,6 +298,17 @@ describe('#collections.info', async () => { expect(body.data.id).toEqual(collection.id); }); + it('should require user member of collection', async () => { + const { user, collection } = await seed(); + collection.private = true; + await collection.save(); + + const res = await server.post('/api/collections.info', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + expect(res.status).toEqual(403); + }); + it('should require authentication', async () => { const res = await server.post('/api/collections.info'); const body = await res.json(); diff --git a/server/api/documents.js b/server/api/documents.js index 397527fd..25a57395 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -14,14 +14,39 @@ const { authorize, cannot } = policy; const router = new Router(); router.post('documents.list', auth(), pagination(), async ctx => { - let { sort = 'updatedAt', direction, collection, user } = ctx.body; + const { sort = 'updatedAt' } = ctx.body; + const collectionId = ctx.body.collection; + const createdById = ctx.body.user; + let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; - let where = { teamId: ctx.state.user.teamId }; - if (collection) where = { ...where, collectionId: collection }; - if (user) where = { ...where, createdById: user }; + // always filter by the current team + const user = ctx.state.user; + let where = { teamId: user.teamId }; - const starredScope = { method: ['withStarred', ctx.state.user.id] }; + // if a specific user is passed then add to filters. If the user doesn't + // exist in the team then nothing will be returned, so no need to check auth + if (createdById) { + ctx.assertUuid(createdById, 'user must be a UUID'); + where = { ...where, createdById }; + } + + // if a specific collection is passed then we need to check auth to view it + if (collectionId) { + ctx.assertUuid(collectionId, 'collection must be a UUID'); + + where = { ...where, collectionId }; + const collection = await Collection.findById(collectionId); + authorize(user, 'read', collection); + + // otherwise, filter by all collections the user has access to + } else { + const collectionIds = await user.collectionIds(); + where = { ...where, collectionId: collectionIds }; + } + + // add the users starred state to the response by default + const starredScope = { method: ['withStarred', user.id] }; const documents = await Document.scope('defaultScope', starredScope).findAll({ where, order: [[sort, direction]], @@ -40,16 +65,21 @@ router.post('documents.list', auth(), pagination(), async ctx => { }); router.post('documents.pinned', auth(), pagination(), async ctx => { - let { sort = 'updatedAt', direction, collection } = ctx.body; + const { sort = 'updatedAt' } = ctx.body; + const collectionId = ctx.body.collection; + let direction = ctx.body.direction; if (direction !== 'ASC') direction = 'DESC'; - ctx.assertPresent(collection, 'collection is required'); + ctx.assertUuid(collectionId, 'collection is required'); const user = ctx.state.user; + const collection = await Collection.findById(collectionId); + authorize(user, 'read', collection); + const starredScope = { method: ['withStarred', user.id] }; const documents = await Document.scope('defaultScope', starredScope).findAll({ where: { teamId: user.teamId, - collectionId: collection, + collectionId, pinnedById: { // $FlowFixMe [Op.ne]: null, @@ -75,6 +105,8 @@ router.post('documents.viewed', auth(), pagination(), async ctx => { if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; + const collectionIds = await user.collectionIds(); + const views = await View.findAll({ where: { userId: user.id }, order: [[sort, direction]], @@ -82,6 +114,9 @@ router.post('documents.viewed', auth(), pagination(), async ctx => { { model: Document, required: true, + where: { + collectionId: collectionIds, + }, include: [ { model: Star, @@ -111,13 +146,28 @@ router.post('documents.starred', auth(), pagination(), async ctx => { if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; + const collectionIds = await user.collectionIds(); + const views = await Star.findAll({ - where: { userId: user.id }, + where: { + userId: user.id, + }, order: [[sort, direction]], include: [ { model: Document, - include: [{ model: Star, as: 'starred', where: { userId: user.id } }], + where: { + collectionId: collectionIds, + }, + include: [ + { + model: Star, + as: 'starred', + where: { + userId: user.id, + }, + }, + ], }, ], offset: ctx.state.pagination.offset, @@ -139,9 +189,15 @@ router.post('documents.drafts', auth(), pagination(), async ctx => { if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; + const collectionIds = await user.collectionIds(); + const documents = await Document.findAll({ - // $FlowFixMe - where: { userId: user.id, publishedAt: { [Op.eq]: null } }, + where: { + userId: user.id, + collectionId: collectionIds, + // $FlowFixMe + publishedAt: { [Op.eq]: null }, + }, order: [[sort, direction]], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, @@ -199,6 +255,7 @@ router.post('documents.revision', auth(), async ctx => { let { id, revisionId } = ctx.body; ctx.assertPresent(id, 'id is required'); ctx.assertPresent(revisionId, 'revisionId is required'); + const document = await Document.findById(id); authorize(ctx.state.user, 'read', document); @@ -346,7 +403,6 @@ router.post('documents.unstar', auth(), async ctx => { router.post('documents.create', auth(), async ctx => { const { title, text, publish, parentDocument, index } = ctx.body; const collectionId = ctx.body.collection; - ctx.assertPresent(collectionId, 'collection is required'); ctx.assertUuid(collectionId, 'collection must be an uuid'); ctx.assertPresent(title, 'title is required'); ctx.assertPresent(text, 'text is required'); diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 6c8355ff..57a108be 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -3,7 +3,12 @@ import TestServer from 'fetch-test-server'; import app from '..'; import { Document, View, Star, Revision } from '../models'; import { flushdb, seed } from '../test/support'; -import { buildShare, buildUser, buildDocument } from '../test/factories'; +import { + buildShare, + buildCollection, + buildUser, + buildDocument, +} from '../test/factories'; const server = new TestServer(app.callback()); @@ -22,6 +27,18 @@ describe('#documents.info', async () => { expect(body.data.id).toEqual(document.id); }); + it('should not return published document in collection not a member of', async () => { + const { user, document, collection } = await seed(); + collection.private = true; + await collection.save(); + + const res = await server.post('/api/documents.info', { + body: { token: user.getJwtToken(), id: document.id }, + }); + + expect(res.status).toEqual(403); + }); + it('should return drafts', async () => { const { user, document } = await seed(); document.publishedAt = null; @@ -36,7 +53,7 @@ describe('#documents.info', async () => { expect(body.data.id).toEqual(document.id); }); - it('should return redacted document from shareId without token', async () => { + it('should return document from shareId without token', async () => { const { document } = await seed(); const share = await buildShare({ documentId: document.id, @@ -141,6 +158,20 @@ describe('#documents.list', async () => { expect(body.data.length).toEqual(1); }); + it('should not return documents in private collections not a member of', async () => { + const { user, collection } = await seed(); + collection.private = true; + await collection.save(); + + const res = await server.post('/api/documents.list', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + it('should allow changing sort direction', async () => { const { user, document } = await seed(); const res = await server.post('/api/documents.list', { @@ -189,6 +220,23 @@ describe('#documents.drafts', async () => { expect(res.status).toEqual(200); expect(body.data.length).toEqual(1); }); + + it('should not return documents in private collections not a member of', async () => { + const { user, document, collection } = await seed(); + document.publishedAt = null; + await document.save(); + + collection.private = true; + await collection.save(); + + const res = await server.post('/api/documents.drafts', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); }); describe('#documents.revision', async () => { @@ -208,6 +256,18 @@ describe('#documents.revision', async () => { expect(body.data[0].title).toEqual(document.title); }); + it('should not return revisions for document in collection not a member of', async () => { + const { user, document, collection } = await seed(); + collection.private = true; + await collection.save(); + + const res = await server.post('/api/documents.revisions', { + body: { token: user.getJwtToken(), id: document.id }, + }); + + expect(res.status).toEqual(403); + }); + it('should require authorization', async () => { const { document } = await seed(); const user = await buildUser(); @@ -296,6 +356,26 @@ describe('#documents.search', async () => { expect(body.data.length).toEqual(0); }); + it('should not return documents in private collections not a member of', async () => { + const { user } = await seed(); + const collection = await buildCollection({ private: true }); + + await buildDocument({ + title: 'search term', + text: 'search term', + publishedAt: null, + teamId: user.teamId, + collectionId: collection.id, + }); + const res = await server.post('/api/documents.search', { + body: { token: user.getJwtToken(), query: 'search term' }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + it('should require authentication', async () => { const res = await server.post('/api/documents.search'); const body = await res.json(); @@ -345,6 +425,21 @@ describe('#documents.viewed', async () => { expect(body.data.length).toEqual(0); }); + 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; + await collection.save(); + + const res = await server.post('/api/documents.viewed', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + it('should require authentication', async () => { const res = await server.post('/api/documents.viewed'); const body = await res.json(); diff --git a/server/api/integrations.js b/server/api/integrations.js index 53b96447..d77ac181 100644 --- a/server/api/integrations.js +++ b/server/api/integrations.js @@ -33,7 +33,7 @@ router.post('integrations.list', auth(), pagination(), async ctx => { router.post('integrations.delete', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const integration = await Integration.findById(id); authorize(ctx.state.user, 'delete', integration); diff --git a/server/api/notificationSettings.js b/server/api/notificationSettings.js index c91d01f5..b9a2ca74 100644 --- a/server/api/notificationSettings.js +++ b/server/api/notificationSettings.js @@ -44,7 +44,7 @@ router.post('notificationSettings.list', auth(), async ctx => { router.post('notificationSettings.delete', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const user = ctx.state.user; const setting = await NotificationSetting.findById(id); @@ -59,7 +59,7 @@ router.post('notificationSettings.delete', auth(), async ctx => { router.post('notificationSettings.unsubscribe', async ctx => { const { id, token } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); ctx.assertPresent(token, 'token is required'); const setting = await NotificationSetting.findById(id); diff --git a/server/api/shares.js b/server/api/shares.js index be3c8c26..6fc1616d 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -80,7 +80,7 @@ router.post('shares.create', auth(), async ctx => { router.post('shares.revoke', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const user = ctx.state.user; const share = await Share.findById(id); diff --git a/server/api/views.js b/server/api/views.js index 0d73387f..8b095508 100644 --- a/server/api/views.js +++ b/server/api/views.js @@ -10,7 +10,7 @@ const router = new Router(); router.post('views.list', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const user = ctx.state.user; const document = await Document.findById(id); @@ -40,7 +40,7 @@ router.post('views.list', auth(), async ctx => { router.post('views.create', auth(), async ctx => { const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + ctx.assertUuid(id, 'id is required'); const user = ctx.state.user; const document = await Document.findById(id); diff --git a/server/middlewares/validation.js b/server/middlewares/validation.js index 30780850..bef46582 100644 --- a/server/middlewares/validation.js +++ b/server/middlewares/validation.js @@ -25,7 +25,7 @@ export default function validation() { }; ctx.assertUuid = (value, message) => { - if (!validator.isUUID(value)) { + if (!validator.isUUID(value.toString())) { throw new ValidationError(message); } }; diff --git a/server/migrations/20181227001547-collection-permissions.js b/server/migrations/20181227001547-collection-permissions.js new file mode 100644 index 00000000..60bdf807 --- /dev/null +++ b/server/migrations/20181227001547-collection-permissions.js @@ -0,0 +1,53 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('collection_users', { + collectionId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'collections', + }, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + }, + }, + permission: { + type: Sequelize.STRING, + allowNull: false + }, + createdById: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + }, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + } + }); + await queryInterface.addColumn('collections', 'private', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }); + + await queryInterface.addIndex('collection_users', ['collectionId', 'userId']); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('collection_users'); + await queryInterface.removeColumn('collections', 'private'); + + await queryInterface.removeIndex('collection_users', ['collectionId', 'userId']); + }, +}; diff --git a/server/models/Collection.js b/server/models/Collection.js index 919a6066..f73d9a57 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -6,6 +6,7 @@ import { DataTypes, sequelize } from '../sequelize'; import { asyncLock } from '../redis'; import events from '../events'; import Document from './Document'; +import CollectionUser from './CollectionUser'; import Event from './Event'; import { welcomeMessage } from '../utils/onboarding'; @@ -26,6 +27,7 @@ const Collection = sequelize.define( name: DataTypes.STRING, description: DataTypes.STRING, color: DataTypes.STRING, + private: DataTypes.BOOLEAN, type: { type: DataTypes.STRING, validate: { isIn: allowedCollectionTypes }, @@ -85,6 +87,11 @@ Collection.associate = models => { foreignKey: 'collectionId', onDelete: 'cascade', }); + Collection.belongsToMany(models.User, { + as: 'users', + through: models.CollectionUser, + foreignKey: 'collectionId', + }); Collection.belongsTo(models.User, { as: 'user', foreignKey: 'creatorId', @@ -92,16 +99,20 @@ Collection.associate = models => { Collection.belongsTo(models.Team, { as: 'team', }); - Collection.addScope('withRecentDocuments', { - include: [ - { - as: 'documents', - limit: 10, - model: models.Document, - order: [['updatedAt', 'DESC']], - }, - ], - }); + Collection.addScope( + 'defaultScope', + { + include: [ + { + model: models.User, + as: 'users', + through: 'collection_users', + paranoid: false, + }, + ], + }, + { override: true } + ); }; Collection.addHook('afterDestroy', async model => { @@ -112,8 +123,6 @@ Collection.addHook('afterDestroy', async model => { }); }); -// Hooks - Collection.addHook('afterCreate', model => events.add({ name: 'collections.create', model }) ); @@ -126,6 +135,22 @@ Collection.addHook('afterUpdate', model => events.add({ name: 'collections.update', model }) ); +Collection.addHook('afterCreate', (model, options) => { + if (model.private) { + return CollectionUser.findOrCreate({ + where: { + collectionId: model.id, + userId: model.creatorId, + }, + defaults: { + permission: 'read_write', + createdById: model.creatorId, + }, + transaction: options.transaction, + }); + } +}); + // Instance methods Collection.prototype.addDocumentToStructure = async function( diff --git a/server/models/CollectionUser.js b/server/models/CollectionUser.js new file mode 100644 index 00000000..a256d643 --- /dev/null +++ b/server/models/CollectionUser.js @@ -0,0 +1,34 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const CollectionUser = sequelize.define( + 'collection_user', + { + permission: { + type: DataTypes.STRING, + validate: { + isIn: [['read', 'read_write']], + }, + }, + }, + { + timestamps: true, + } +); + +CollectionUser.associate = models => { + CollectionUser.belongsTo(models.Collection, { + as: 'collection', + foreignKey: 'collectionId', + }); + CollectionUser.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + }); + CollectionUser.belongsTo(models.User, { + as: 'createdBy', + foreignKey: 'createdById', + }); +}; + +export default CollectionUser; diff --git a/server/models/Document.js b/server/models/Document.js index 2cca8517..804570f1 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -220,7 +220,7 @@ Document.searchForUser = async ( ts_headline('english', "text", plainto_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext" FROM documents WHERE "searchVector" @@ plainto_tsquery('english', :query) AND - "teamId" = '${user.teamId}'::uuid AND + "collectionId" IN(:collectionIds) AND "deletedAt" IS NULL AND ("publishedAt" IS NOT NULL OR "createdById" = '${user.id}') ORDER BY @@ -230,20 +230,24 @@ Document.searchForUser = async ( OFFSET :offset; `; + const collectionIds = await user.collectionIds(); const results = await sequelize.query(sql, { type: sequelize.QueryTypes.SELECT, replacements: { query, limit, offset, + collectionIds, }, }); - // Second query to get associated document data + // Final query to get associated document data const documents = await Document.scope({ method: ['withViews', user.id], }).findAll({ - where: { id: map(results, 'id') }, + where: { + id: map(results, 'id'), + }, include: [ { model: Collection, as: 'collection' }, { model: User, as: 'createdBy', paranoid: false }, diff --git a/server/models/User.js b/server/models/User.js index 24231310..f2070f9c 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -6,7 +6,7 @@ import subMinutes from 'date-fns/sub_minutes'; import { DataTypes, sequelize, encryptedFields } from '../sequelize'; import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3'; import { sendEmail } from '../mailer'; -import { Star, NotificationSetting, ApiKey } from '.'; +import { Star, Collection, NotificationSetting, ApiKey } from '.'; const User = sequelize.define( 'user', @@ -54,6 +54,25 @@ User.associate = models => { }; // Instance methods +User.prototype.collectionIds = async function() { + let models = await Collection.findAll({ + attributes: ['id', 'private'], + where: { teamId: this.teamId }, + include: [ + { + model: User, + through: 'collection_users', + as: 'users', + where: { id: this.id }, + required: false, + }, + ], + }); + + // Filter collections that are private and don't have an association + return models.filter(c => !c.private || c.users.length).map(c => c.id); +}; + User.prototype.updateActiveAt = function(ip) { const fiveMinutesAgo = subMinutes(new Date(), 5); diff --git a/server/models/index.js b/server/models/index.js index c7694da2..e599c1c6 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -2,6 +2,7 @@ import ApiKey from './ApiKey'; import Authentication from './Authentication'; import Collection from './Collection'; +import CollectionUser from './CollectionUser'; import Document from './Document'; import Event from './Event'; import Integration from './Integration'; @@ -18,6 +19,7 @@ const models = { ApiKey, Authentication, Collection, + CollectionUser, Document, Event, Integration, @@ -42,6 +44,7 @@ export { ApiKey, Authentication, Collection, + CollectionUser, Document, Event, Integration, diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js index 6ce20c1d..ef4359f5 100644 --- a/server/pages/developers/Api.js +++ b/server/pages/developers/Api.js @@ -152,11 +152,12 @@ export default function Pricing() { - This method allows you to modify already created document. + This method allows you to modify an already created collection. + + + + This method allows you to add a user to a private collection. + + + + + + + + + + This method allows you to remove a user from a private collection. + + + + + + + + + + This method allows you to list users with access to a private + collection. + + + + + + Delete a collection and all of its documents. This action can’t be diff --git a/server/policies/collection.js b/server/policies/collection.js index 0b9e09e1..d9c3f4e5 100644 --- a/server/policies/collection.js +++ b/server/policies/collection.js @@ -1,5 +1,6 @@ // @flow import policy from './policy'; +import { map } from 'lodash'; import { Collection, User } from '../models'; import { AdminRequiredError } from '../errors'; @@ -11,12 +12,27 @@ allow( User, ['read', 'publish', 'update', 'export'], Collection, - (user, collection) => collection && user.teamId === collection.teamId + (user, collection) => { + if (!collection || user.teamId !== collection.teamId) return false; + + if ( + collection.private && + !map(collection.users, u => u.id).includes(user.id) + ) + return false; + + return true; + } ); allow(User, 'delete', Collection, (user, collection) => { if (!collection || user.teamId !== collection.teamId) return false; - if (user.id === collection.creatorId) return true; + + if (collection.private && !map(collection.users, u => u.id).includes(user.id)) + return false; + if (user.isAdmin) return true; + if (user.id === collection.creatorId) return true; + throw new AdminRequiredError(); }); diff --git a/server/policies/document.js b/server/policies/document.js index c0a68165..67546148 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -2,7 +2,7 @@ import policy from './policy'; import { Document, Revision, User } from '../models'; -const { allow } = policy; +const { allow, authorize } = policy; allow(User, 'create', Document); @@ -10,7 +10,13 @@ allow( User, ['read', 'update', 'delete', 'share'], Document, - (user, document) => user.teamId === document.teamId + (user, document) => { + if (document.collection) { + authorize(user, 'read', document.collection); + } + + return user.teamId === document.teamId; + } ); allow( diff --git a/server/presenters/collection.js b/server/presenters/collection.js index c27fc185..cd3ffa90 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -1,6 +1,5 @@ // @flow import { Collection } from '../models'; -import presentDocument from './document'; import naturalSort from '../../shared/utils/naturalSort'; type Document = { @@ -29,9 +28,9 @@ async function present(ctx: Object, collection: Collection) { description: collection.description, color: collection.color || '#4E5C6E', type: collection.type, + private: collection.private, createdAt: collection.createdAt, updatedAt: collection.updatedAt, - recentDocuments: undefined, documents: undefined, }; @@ -40,14 +39,6 @@ async function present(ctx: Object, collection: Collection) { data.documents = sortDocuments(collection.documentStructure); } - if (collection.documents) { - data.recentDocuments = await Promise.all( - collection.documents.map( - async document => await presentDocument(ctx, document) - ) - ); - } - return data; } diff --git a/server/presenters/document.js b/server/presenters/document.js index 50388e65..32a401a1 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -1,12 +1,9 @@ // @flow -import _ from 'lodash'; -import Sequelize from 'sequelize'; +import { takeRight } from 'lodash'; import { User, Document } from '../models'; import presentUser from './user'; import presentCollection from './collection'; -const Op = Sequelize.Op; - type Options = { isPublic?: boolean, }; @@ -43,7 +40,6 @@ async function present(ctx: Object, document: Document, options: ?Options) { revision: document.revisionCount, pinned: undefined, collectionId: undefined, - collaboratorCount: undefined, collection: undefined, views: undefined, }; @@ -67,14 +63,9 @@ async function present(ctx: Object, document: Document, options: ?Options) { // This could be further optimized by using ctx.cache data.collaborators = await User.findAll({ where: { - id: { - // $FlowFixMe - [Op.in]: _.takeRight(document.collaboratorIds, 10) || [], - }, + id: takeRight(document.collaboratorIds, 10) || [], }, }).map(user => presentUser(ctx, user)); - - data.collaboratorCount = document.collaboratorIds.length; } return data; diff --git a/server/services/notifications.js b/server/services/notifications.js index 07e2a9dc..3e13e069 100644 --- a/server/services/notifications.js +++ b/server/services/notifications.js @@ -77,6 +77,7 @@ export default class Notifications { ], }); if (!collection) return; + if (collection.private) return; const notificationSettings = await NotificationSetting.findAll({ where: { diff --git a/yarn.lock b/yarn.lock index 57eabe7f..cb376995 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7448,9 +7448,9 @@ outline-icons@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.0.3.tgz#f0928a8bbc7e7ff4ea6762eee8fb2995d477941e" -outline-icons@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.5.0.tgz#fc2f9cacba42af6eb4a98c7454493d7445c97c8f" +outline-icons@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.6.0.tgz#6c7897d354e6bd77ca5498cd3a989b8cb9482574" oy-vey@^0.10.0: version "0.10.0"