From b42e9737b63d22a1bed93101222b0a952bee0f8e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 5 Oct 2019 18:42:03 -0700 Subject: [PATCH] feat: Memberships (#1032) * WIP * feat: Add collection.memberships endpoint * feat: Add ability to filter collection.memberships with query * WIP * Merge stashed work * feat: Add ability to filter memberships by permission * continued refactoring * paginated list component * Collection member management * fix: Incorrect policy data sent down after collection.update * Reduce duplication, add empty state * cleanup * fix: Modal close should be a real button * fix: Allow opening edit from modal * fix: remove unused methods * test: fix * Passing test suite * Refactor * fix: Flow UI errors * test: Add collections.update tests * lint * test: moar tests * fix: Missing scopes, more missing tests * fix: Handle collection privacy change over socket * fix: More membership scopes * fix: view endpoint permissions * fix: respond to privacy change on socket event * policy driven menus * fix: share endpoint policies * chore: Use policies to drive documents UI * alignment * fix: Header height * fix: Correct behavior when collection becomes private * fix: Header height for read-only collection * send id's over socket instead of serialized objects * fix: Remote policy change * fix: reduce collection fetching * More websocket efficiencies * fix: Document collection pinning * fix: Restored ability to edit drafts fix: Removed ability to star drafts * fix: Require write permissions to pin doc to collection * fix: Header title overlaying document actions at small screen sizes * fix: Jank on load caused by previous commit * fix: Double collection fetch post-publish * fix: Hide publish button if draft is in no longer accessible collection * fix: Always allow deleting drafts fix: Improved handling of deleted documents * feat: Show collections in drafts view feat: Show more obvious 'draft' badge on documents * fix: incorrect policies after publish to private collection * fix: Duplicating a draft publishes it --- .flowconfig | 1 + .gitignore | 1 + app/components/Actions.js | 1 + app/components/Badge.js | 6 +- .../DocumentPreview/DocumentPreview.js | 14 + app/components/DropdownMenu/DropdownMenu.js | 10 +- .../DropdownMenu/DropdownMenuItem.js | 14 +- app/components/InputRich.js | 28 +- app/components/InputSelect.js | 72 +++++ app/components/List/Item.js | 4 + app/components/Modal.js | 7 +- app/components/PaginatedList.js | 95 ++++++ app/components/PublishingInfo.js | 2 +- app/components/Sidebar/Main.js | 2 +- app/components/Sidebar/Settings.js | 2 +- .../Sidebar/components/Collections.js | 7 +- app/components/SocketProvider.js | 148 +++++++-- app/menus/CollectionMenu.js | 85 ++--- app/menus/DocumentMenu.js | 149 ++++----- app/menus/NewDocumentMenu.js | 37 ++- app/menus/RevisionMenu.js | 13 +- app/menus/ShareMenu.js | 12 +- app/menus/UserMenu.js | 10 +- app/models/Collection.js | 44 +-- app/models/Document.js | 14 +- app/models/Membership.js | 22 ++ app/scenes/Collection.js | 67 ++-- app/scenes/CollectionEdit.js | 22 +- .../AddPeopleToCollection.js | 122 ++++++++ .../CollectionMembers/CollectionMembers.js | 143 +++++++++ .../components/MemberListItem.js | 82 +++++ .../components/UserListItem.js | 11 +- app/scenes/CollectionMembers/index.js | 3 + .../CollectionPermissions.js | 163 ---------- .../components/MemberListItem.js | 49 --- app/scenes/CollectionPermissions/index.js | 3 - app/scenes/Document/Document.js | 33 +- app/scenes/Document/components/Header.js | 52 ++-- app/scenes/Drafts.js | 2 +- app/scenes/Invite.js | 1 + app/stores/BaseStore.js | 9 +- app/stores/DocumentsStore.js | 15 +- app/stores/MembershipsStore.js | 82 +++++ app/stores/PoliciesStore.js | 2 +- app/stores/RootStore.js | 4 + app/stores/UsersStore.js | 29 ++ .../__snapshots__/collections.test.js.snap | 18 ++ server/api/collections.js | 169 +++++++--- server/api/collections.test.js | 290 +++++++++++++++++- server/api/documents.js | 103 ++++--- server/api/documents.test.js | 233 +++++++++++++- server/api/hooks.js | 7 +- server/api/shares.js | 2 +- server/api/shares.test.js | 22 ++ server/api/users.js | 19 +- server/api/users.test.js | 16 + server/api/views.js | 4 +- server/api/views.test.js | 47 ++- server/index.js | 4 +- .../migrations/20190811231511-maintainers.js | 25 ++ server/models/Collection.js | 30 +- server/models/CollectionUser.js | 3 +- server/models/Document.js | 69 +++-- server/models/User.js | 6 +- server/pages/developers/Api.js | 25 +- server/policies/collection.js | 64 ++-- server/policies/document.js | 84 ++++- server/presenters/index.js | 2 + server/presenters/membership.js | 18 ++ server/presenters/slackAttachment.js | 7 +- server/services/websockets.js | 162 +++++++--- shared/components/Breadcrumb.js | 2 +- 72 files changed, 2360 insertions(+), 765 deletions(-) create mode 100644 app/components/InputSelect.js create mode 100644 app/components/PaginatedList.js create mode 100644 app/models/Membership.js create mode 100644 app/scenes/CollectionMembers/AddPeopleToCollection.js create mode 100644 app/scenes/CollectionMembers/CollectionMembers.js create mode 100644 app/scenes/CollectionMembers/components/MemberListItem.js rename app/scenes/{CollectionPermissions => CollectionMembers}/components/UserListItem.js (67%) create mode 100644 app/scenes/CollectionMembers/index.js delete mode 100644 app/scenes/CollectionPermissions/CollectionPermissions.js delete mode 100644 app/scenes/CollectionPermissions/components/MemberListItem.js delete mode 100644 app/scenes/CollectionPermissions/index.js create mode 100644 app/stores/MembershipsStore.js create mode 100644 server/migrations/20190811231511-maintainers.js create mode 100644 server/presenters/membership.js diff --git a/.flowconfig b/.flowconfig index ba796d40..62ea6445 100644 --- a/.flowconfig +++ b/.flowconfig @@ -14,6 +14,7 @@ .*/node_modules/slate-edit-list/.* .*/node_modules/slate-prism/.* .*/node_modules/config-chain/.* +.*/server/scripts/.* *.test.js [libs] diff --git a/.gitignore b/.gitignore index f00129f4..8ce4ef8e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ dist node_modules/* +server/scripts .env .log npm-debug.log diff --git a/app/components/Actions.js b/app/components/Actions.js index f2c57f45..ccb5a868 100644 --- a/app/components/Actions.js +++ b/app/components/Actions.js @@ -7,6 +7,7 @@ export const Action = styled(Flex)` justify-content: center; align-items: center; padding: 0 0 0 12px; + height: 32px; font-size: 15px; flex-shrink: 0; diff --git a/app/components/Badge.js b/app/components/Badge.js index d3d6f351..ba310e3a 100644 --- a/app/components/Badge.js +++ b/app/components/Badge.js @@ -5,9 +5,9 @@ const Badge = styled.span` margin-left: 10px; padding: 2px 6px 3px; background-color: ${({ admin, theme }) => - admin ? theme.primary : theme.smokeDark}; - color: ${({ admin, theme }) => (admin ? theme.white : theme.text)}; - border-radius: 2px; + admin ? theme.primary : theme.textTertiary}; + color: ${({ admin, theme }) => (admin ? theme.white : theme.background)}; + border-radius: 4px; font-size: 11px; font-weight: 500; text-transform: uppercase; diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 84c7a9e8..026d772c 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -5,6 +5,8 @@ import { Link } from 'react-router-dom'; import { StarredIcon } from 'outline-icons'; import styled, { withTheme } from 'styled-components'; import Flex from 'shared/components/Flex'; +import Badge from 'components/Badge'; +import Tooltip from 'components/Tooltip'; import Highlight from 'components/Highlight'; import PublishingInfo from 'components/PublishingInfo'; import DocumentMenu from 'menus/DocumentMenu'; @@ -17,6 +19,7 @@ type Props = { showCollection?: boolean, showPublished?: boolean, showPin?: boolean, + showDraft?: boolean, }; const StyledStar = withTheme(styled(({ solid, theme, ...props }) => ( @@ -130,6 +133,7 @@ class DocumentPreview extends React.Component { showCollection, showPublished, showPin, + showDraft = true, highlight, context, ...rest @@ -159,6 +163,16 @@ class DocumentPreview extends React.Component { )} )} + {document.isDraft && + showDraft && ( + + Draft + + )} {!queryIsInTitle && ( diff --git a/app/components/DropdownMenu/DropdownMenu.js b/app/components/DropdownMenu/DropdownMenu.js index 139b17dd..cfeeeab3 100644 --- a/app/components/DropdownMenu/DropdownMenu.js +++ b/app/components/DropdownMenu/DropdownMenu.js @@ -4,9 +4,11 @@ import invariant from 'invariant'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; import { PortalWithState } from 'react-portal'; +import { MoreIcon } from 'outline-icons'; import styled from 'styled-components'; import Flex from 'shared/components/Flex'; import { fadeAndScaleIn } from 'shared/styles/animations'; +import NudeButton from 'components/NudeButton'; let previousClosePortal; @@ -15,7 +17,7 @@ type Children = | ((options: { closePortal: () => void }) => React.Node); type Props = { - label: React.Node, + label?: React.Node, onOpen?: () => void, onClose?: () => void, children?: Children, @@ -76,7 +78,11 @@ class DropdownMenu extends React.Component { {({ closePortal, openPortal, portal }) => ( {portal( { +const DropdownMenuItem = ({ onClick, children, disabled, ...rest }: Props) => { return ( - + {children} ); @@ -33,9 +37,13 @@ const MenuItem = styled.a` margin-right: 8px; } + svg { + opacity: ${props => (props.disabled ? '.5' : 1)}; + } + ${props => props.disabled - ? '' + ? 'pointer-events: none;' : ` &:hover { diff --git a/app/components/InputRich.js b/app/components/InputRich.js index 82b91c4c..2512ccc7 100644 --- a/app/components/InputRich.js +++ b/app/components/InputRich.js @@ -3,7 +3,7 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; import styled, { withTheme } from 'styled-components'; -import Input, { LabelText, Outline } from 'components/Input'; +import { LabelText, Outline } from 'components/Input'; type Props = { label: string, @@ -41,26 +41,22 @@ class InputRich extends React.Component { return ( {label} - {Editor ? ( - + + + {Editor ? ( - - ) : ( - - )} + ) : ( + 'Loading…' + )} + ); } diff --git a/app/components/InputSelect.js b/app/components/InputSelect.js new file mode 100644 index 00000000..d60689b7 --- /dev/null +++ b/app/components/InputSelect.js @@ -0,0 +1,72 @@ +// @flow +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { observable } from 'mobx'; +import styled from 'styled-components'; +import VisuallyHidden from 'components/VisuallyHidden'; +import { Outline, LabelText } from './Input'; + +const Select = styled.select` + border: 0; + flex: 1; + padding: 8px 12px; + outline: none; + background: none; + color: ${props => props.theme.text}; + + &:disabled, + &::placeholder { + color: ${props => props.theme.placeholder}; + } +`; + +type Option = { label: string, value: string }; + +export type Props = { + value?: string, + label?: string, + className?: string, + labelHidden?: boolean, + options: Option[], +}; + +@observer +class InputSelect extends React.Component { + @observable focused: boolean = false; + + handleBlur = () => { + this.focused = false; + }; + + handleFocus = () => { + this.focused = true; + }; + + render() { + const { label, className, labelHidden, options, ...rest } = this.props; + + const wrappedLabel = {label}; + + return ( + + ); + } +} + +export default InputSelect; diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 5491fa2e..165967fb 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -30,6 +30,10 @@ const Wrapper = styled.li` padding: ${props => (props.compact ? '8px' : '12px')} 0; margin: 0; border-bottom: 1px solid ${props => props.theme.divider}; + + &:last-child { + border-bottom: 0; + } `; const Image = styled(Flex)` diff --git a/app/components/Modal.js b/app/components/Modal.js index c7488019..c23043f6 100644 --- a/app/components/Modal.js +++ b/app/components/Modal.js @@ -6,6 +6,7 @@ import breakpoint from 'styled-components-breakpoint'; import ReactModal from 'react-modal'; import { transparentize } from 'polished'; import { CloseIcon } from 'outline-icons'; +import NudeButton from 'components/NudeButton'; import { fadeAndScaleIn } from 'shared/styles/animations'; import Flex from 'shared/components/Flex'; @@ -90,16 +91,18 @@ const StyledModal = styled(ReactModal)` const Esc = styled.span` display: block; text-align: center; - margin-top: -10px; font-size: 13px; + height: 1em; `; -const Close = styled.a` +const Close = styled(NudeButton)` position: fixed; top: 16px; right: 16px; opacity: 0.75; color: ${props => props.theme.text}; + width: auto; + height: auto; &:hover { opacity: 1; diff --git a/app/components/PaginatedList.js b/app/components/PaginatedList.js new file mode 100644 index 00000000..19fffa78 --- /dev/null +++ b/app/components/PaginatedList.js @@ -0,0 +1,95 @@ +// @flow +import * as React from 'react'; +import { observable, action } from 'mobx'; +import { observer } from 'mobx-react'; +import Waypoint from 'react-waypoint'; +import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; + +import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore'; +import { ListPlaceholder } from 'components/LoadingPlaceholder'; + +type Props = { + fetch?: (options: ?Object) => Promise, + options?: Object, + empty?: React.Node, + items: any[], + renderItem: any => React.Node, +}; + +@observer +class PaginatedList extends React.Component { + isInitiallyLoaded: boolean = false; + @observable isLoaded: boolean = false; + @observable isFetchingMore: boolean = false; + @observable isFetching: boolean = false; + @observable offset: number = 0; + @observable allowLoadMore: boolean = true; + + componentDidMount() { + this.isInitiallyLoaded = !!this.props.items.length; + this.fetchResults(); + } + + fetchResults = async () => { + if (!this.props.fetch) return; + + this.isFetching = true; + + const limit = DEFAULT_PAGINATION_LIMIT; + const results = await this.props.fetch({ + limit, + offset: this.offset, + ...this.props.options, + }); + + if (results && (results.length === 0 || results.length < limit)) { + this.allowLoadMore = false; + } else { + this.offset += limit; + } + + this.isLoaded = true; + this.isFetching = false; + this.isFetchingMore = false; + }; + + @action + loadMoreResults = async () => { + // Don't paginate if there aren't more results or we’re in the middle of fetching + if (!this.allowLoadMore || this.isFetching) return; + + this.isFetchingMore = true; + await this.fetchResults(); + }; + + render() { + const { items, empty } = this.props; + + const showLoading = + this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded; + const showEmpty = !items.length || showLoading; + const showList = (this.isLoaded || this.isInitiallyLoaded) && !showLoading; + + return ( + + {showEmpty && empty} + {showList && ( + + + {items.map(this.props.renderItem)} + + {this.allowLoadMore && ( + + )} + + )} + {showLoading && } + + ); + } +} + +export default PaginatedList; diff --git a/app/components/PublishingInfo.js b/app/components/PublishingInfo.js index 31ef3521..78a1582f 100644 --- a/app/components/PublishingInfo.js +++ b/app/components/PublishingInfo.js @@ -91,7 +91,7 @@ function PublishingInfo({  in  - {isDraft ? 'Drafts' : } + )} diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 30256adf..22020ceb 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -62,7 +62,7 @@ class MainSidebar extends React.Component { if (!user || !team) return null; const draftDocumentsCount = documents.drafts.length; - const can = policies.abilties(team.id); + const can = policies.abilities(team.id); return ( diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index c3c687ad..81f6661d 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -43,7 +43,7 @@ class SettingsSidebar extends React.Component { const { team } = auth; if (!team) return null; - const can = policies.abilties(team.id); + const can = policies.abilities(team.id); return ( diff --git a/app/components/Sidebar/components/Collections.js b/app/components/Sidebar/components/Collections.js index 1b01def4..254e7d62 100644 --- a/app/components/Sidebar/components/Collections.js +++ b/app/components/Sidebar/components/Collections.js @@ -13,11 +13,13 @@ import CollectionLink from './CollectionLink'; import Fade from 'components/Fade'; import CollectionsStore from 'stores/CollectionsStore'; +import PoliciesStore from 'stores/PoliciesStore'; import UiStore from 'stores/UiStore'; import DocumentsStore from 'stores/DocumentsStore'; type Props = { history: RouterHistory, + policies: PoliciesStore, collections: CollectionsStore, documents: DocumentsStore, onCreateCollection: () => void, @@ -41,6 +43,9 @@ class Collections extends React.Component { const { activeCollectionId } = this.props.ui; if (!activeCollectionId) return; + const can = this.props.policies.abilities(activeCollectionId); + if (!can.update) return; + this.props.history.push(newDocumentUrl(activeCollectionId)); } @@ -75,6 +80,6 @@ class Collections extends React.Component { } } -export default inject('collections', 'ui', 'documents')( +export default inject('collections', 'ui', 'documents', 'policies')( withRouter(Collections) ); diff --git a/app/components/SocketProvider.js b/app/components/SocketProvider.js index d34a1877..53a68122 100644 --- a/app/components/SocketProvider.js +++ b/app/components/SocketProvider.js @@ -1,9 +1,12 @@ // @flow import * as React from 'react'; import { inject } from 'mobx-react'; +import { find } from 'lodash'; import io from 'socket.io-client'; import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; +import MembershipsStore from 'stores/MembershipsStore'; +import PoliciesStore from 'stores/PoliciesStore'; import AuthStore from 'stores/AuthStore'; import UiStore from 'stores/UiStore'; @@ -13,6 +16,8 @@ type Props = { children: React.Node, documents: DocumentsStore, collections: CollectionsStore, + memberships: MembershipsStore, + policies: PoliciesStore, auth: AuthStore, ui: UiStore, }; @@ -27,34 +32,81 @@ class SocketProvider extends React.Component { path: '/realtime', }); - const { auth, ui, documents, collections } = this.props; + const { + auth, + ui, + documents, + collections, + memberships, + policies, + } = this.props; if (!auth.token) return; this.socket.on('connect', () => { this.socket.emit('authentication', { token: auth.token, }); + this.socket.on('unauthorized', err => { ui.showToast(err.message); throw err; }); - this.socket.on('entities', event => { - if (event.documents) { - event.documents.forEach(doc => { - if (doc.deletedAt) { - documents.remove(doc.id); - } else { - documents.add(doc); + + this.socket.on('entities', async event => { + if (event.documentIds) { + for (const documentDescriptor of event.documentIds) { + const documentId = documentDescriptor.id; + let document = documents.get(documentId) || {}; + + if (event.event === 'documents.delete') { + documents.remove(documentId); + continue; + } + + // if we already have the latest version (it was us that performed the change) + // the we don't need to update anything either. + const { title, updatedAt } = document; + if (updatedAt === documentDescriptor.updatedAt) { + continue; + } + + // otherwise, grab the latest version of the document + try { + document = await documents.fetch(documentId, { + force: true, + }); + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 403) { + documents.remove(documentId); + return; + } + } + + // if the title changed then we need to update the collection also + if (title !== document.title) { + if (!event.collectionIds) { + event.collectionIds = []; + } + + const existing = find(event.collectionIds, { + id: document.collectionId, + }); + + if (!existing) { + event.collectionIds.push({ + id: document.collectionId, + }); + } } // TODO: Move this to the document scene once data loading // has been refactored to be friendlier there. if ( auth.user && - doc.id === ui.activeDocumentId && - doc.updatedBy.id !== auth.user.id + documentId === ui.activeDocumentId && + document.updatedBy.id !== auth.user.id ) { - ui.showToast(`Document updated by ${doc.updatedBy.name}`, { + ui.showToast(`Document updated by ${document.updatedBy.name}`, { timeout: 30 * 1000, action: { text: 'Refresh', @@ -62,26 +114,69 @@ class SocketProvider extends React.Component { }, }); } - }); + } } - if (event.collections) { - event.collections.forEach(collection => { - if (collection.deletedAt) { - collections.remove(collection.id); - documents.removeCollectionDocuments(collection.id); - } else { - collections.add(collection); + + if (event.collectionIds) { + for (const collectionDescriptor of event.collectionIds) { + const collectionId = collectionDescriptor.id; + const collection = collections.get(collectionId) || {}; + + if (event.event === 'collections.delete') { + documents.remove(collectionId); + continue; } - }); + + // if we already have the latest version (it was us that performed the change) + // the we don't need to update anything either. + const { updatedAt } = collection; + if (updatedAt === collectionDescriptor.updatedAt) { + continue; + } + + try { + await collections.fetch(collectionId, { force: true }); + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 403) { + collections.remove(collectionId); + documents.removeCollectionDocuments(collectionId); + memberships.removeCollectionMemberships(collectionId); + return; + } + } + } } }); + this.socket.on('documents.star', event => { documents.starredIds.set(event.documentId, true); }); + this.socket.on('documents.unstar', event => { documents.starredIds.set(event.documentId, false); }); + this.socket.on('collections.add_user', event => { + if (auth.user && event.userId === auth.user.id) { + collections.fetch(event.collectionId, { force: true }); + } + + // Document policies might need updating as the permission changes + documents.inCollection(event.collectionId).forEach(document => { + policies.remove(document.id); + }); + }); + + this.socket.on('collections.remove_user', event => { + if (auth.user && event.userId === auth.user.id) { + collections.remove(event.collectionId); + memberships.removeCollectionMemberships(event.collectionId); + documents.removeCollectionDocuments(event.collectionId); + } else { + memberships.remove(`${event.userId}-${event.collectionId}`); + } + }); + // received a message from the API server that we should request // to join a specific room. Forward that to the ws server. this.socket.on('join', event => { @@ -96,6 +191,10 @@ class SocketProvider extends React.Component { }); } + componentWillUnmount() { + this.socket.disconnect(); + } + render() { return ( @@ -105,4 +204,11 @@ class SocketProvider extends React.Component { } } -export default inject('auth', 'ui', 'documents', 'collections')(SocketProvider); +export default inject( + 'auth', + 'ui', + 'documents', + 'collections', + 'memberships', + 'policies' +)(SocketProvider); diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index 941c3c01..8a827cdf 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -4,9 +4,8 @@ import { observable } from 'mobx'; import { inject, observer } from 'mobx-react'; import { withRouter, type RouterHistory } from 'react-router-dom'; import styled from 'styled-components'; -import { MoreIcon } from 'outline-icons'; import Modal from 'components/Modal'; -import CollectionPermissions from 'scenes/CollectionPermissions'; +import CollectionMembers from 'scenes/CollectionMembers'; import { newDocumentUrl } from 'utils/routeHelpers'; import getDataTransferFiles from 'utils/getDataTransferFiles'; @@ -14,12 +13,13 @@ import importFile from 'utils/importFile'; import Collection from 'models/Collection'; import UiStore from 'stores/UiStore'; import DocumentsStore from 'stores/DocumentsStore'; +import PoliciesStore from 'stores/PoliciesStore'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; -import NudeButton from 'components/NudeButton'; type Props = { position?: 'left' | 'right' | 'center', ui: UiStore, + policies: PoliciesStore, documents: DocumentsStore, collection: Collection, history: RouterHistory, @@ -30,7 +30,7 @@ type Props = { @observer class CollectionMenu extends React.Component { file: ?HTMLInputElement; - @observable permissionsModalOpen: boolean = false; + @observable membersModalOpen: boolean = false; @observable redirectTo: ?string; onNewDocument = (ev: SyntheticEvent<>) => { @@ -81,15 +81,16 @@ class CollectionMenu extends React.Component { onPermissions = (ev: SyntheticEvent<>) => { ev.preventDefault(); - this.permissionsModalOpen = true; + this.membersModalOpen = true; }; - handlePermissionsModalClose = () => { - this.permissionsModalOpen = false; + handleMembersModalClose = () => { + this.membersModalOpen = false; }; render() { - const { collection, position, onOpen, onClose } = this.props; + const { policies, collection, position, onOpen, onClose } = this.props; + const can = policies.abilities(collection.id); return ( @@ -100,44 +101,48 @@ class CollectionMenu extends React.Component { accept="text/markdown, text/plain" /> - - - - - } - onOpen={onOpen} - onClose={onClose} - position={position} - > + {collection && ( - - New document - - - Import document - -
- Edit… - - Permissions… - - - Export… - + {can.update && ( + + New document + + )} + {can.update && ( + + Import document + + )} + {can.update &&
} + {can.update && ( + Edit… + )} + {can.update && ( + + Members… + + )} + {can.export && ( + + Export… + + )}
)} - Delete… + {can.delete && ( + Delete… + )}
); @@ -151,4 +156,6 @@ const HiddenInput = styled.input` visibility: hidden; `; -export default inject('ui', 'documents')(withRouter(CollectionMenu)); +export default inject('ui', 'documents', 'policies')( + withRouter(CollectionMenu) +); diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 1e3c4484..f37eed36 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -3,12 +3,12 @@ import * as React from 'react'; import { Redirect } from 'react-router-dom'; import { observable } from 'mobx'; import { inject, observer } from 'mobx-react'; -import { MoreIcon } from 'outline-icons'; import Document from 'models/Document'; import UiStore from 'stores/UiStore'; import AuthStore from 'stores/AuthStore'; import CollectionStore from 'stores/CollectionsStore'; +import PoliciesStore from 'stores/PoliciesStore'; import { documentMoveUrl, documentEditUrl, @@ -16,7 +16,6 @@ import { newDocumentUrl, } from 'utils/routeHelpers'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; -import NudeButton from 'components/NudeButton'; type Props = { ui: UiStore, @@ -24,6 +23,7 @@ type Props = { position?: 'left' | 'right' | 'center', document: Document, collections: CollectionStore, + policies: PoliciesStore, className: string, showPrint?: boolean, showToggleEmbeds?: boolean, @@ -111,6 +111,7 @@ class DocumentMenu extends React.Component { if (this.redirectTo) return ; const { + policies, document, position, className, @@ -120,110 +121,94 @@ class DocumentMenu extends React.Component { onOpen, onClose, } = this.props; - const canShareDocuments = auth.team && auth.team.sharing; - if (document.isArchived) { - return ( - - - - } - className={className} - > - - Restore - - - Delete… - - - ); - } + const can = policies.abilities(document.id); + const canShareDocuments = can.share && auth.team && auth.team.sharing; return ( - - - } className={className} position={position} onOpen={onOpen} onClose={onClose} > - {!document.isDraft ? ( - - {showPin && - (document.pinned ? ( + {can.unarchive && ( + + Restore + + )} + {showPin && + (document.pinned + ? can.unpin && ( Unpin - ) : ( + ) + : can.pin && ( Pin to collection ))} - {document.isStarred ? ( + {document.isStarred + ? can.unstar && ( Unstar - ) : ( + ) + : can.star && ( Star )} - {canShareDocuments && ( - - Share link… - - )} -
- - Document history - - - New child document - - Edit - - Duplicate - - - Archive - - - Delete… - - Move… -
- ) : ( - - {canShareDocuments && ( - - Share link… - - )} - - Delete… - - + {canShareDocuments && ( + + Share link… + )}
- - Download - + {can.read && ( + + Document history + + )} + {can.update && ( + + New child document + + )} + {can.update && ( + Edit + )} + {can.update && ( + + Duplicate + + )} + {can.archive && ( + + Archive + + )} + {can.delete && ( + + Delete… + + )} + {can.move && ( + Move… + )} +
+ {can.download && ( + + Download + + )} {showPrint && ( Print )} @@ -232,4 +217,4 @@ class DocumentMenu extends React.Component { } } -export default inject('ui', 'auth', 'collections')(DocumentMenu); +export default inject('ui', 'auth', 'collections', 'policies')(DocumentMenu); diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js index a8100cb3..abc15366 100644 --- a/app/menus/NewDocumentMenu.js +++ b/app/menus/NewDocumentMenu.js @@ -7,12 +7,14 @@ import { PlusIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons'; import { newDocumentUrl } from 'utils/routeHelpers'; import CollectionsStore from 'stores/CollectionsStore'; +import PoliciesStore from 'stores/PoliciesStore'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; import Button from 'components/Button'; type Props = { label?: React.Node, collections: CollectionsStore, + policies: PoliciesStore, }; @observer @@ -38,7 +40,7 @@ class NewDocumentMenu extends React.Component { render() { if (this.redirectTo) return ; - const { collections, label, ...rest } = this.props; + const { collections, policies, label, ...rest } = this.props; return ( { {...rest} > Choose a collection… - {collections.orderedData.map(collection => ( - this.handleNewDocument(collection.id)} - > - {collection.private ? ( - - ) : ( - - )}{' '} - {collection.name} - - ))} + {collections.orderedData.map(collection => { + const can = policies.abilities(collection.id); + + return ( + this.handleNewDocument(collection.id)} + disabled={!can.update} + > + {collection.private ? ( + + ) : ( + + )}{' '} + {collection.name} + + ); + })} ); } } -export default inject('collections')(NewDocumentMenu); +export default inject('collections', 'policies')(NewDocumentMenu); diff --git a/app/menus/RevisionMenu.js b/app/menus/RevisionMenu.js index 5a012b72..aa29d3f3 100644 --- a/app/menus/RevisionMenu.js +++ b/app/menus/RevisionMenu.js @@ -2,9 +2,7 @@ import * as React from 'react'; import { withRouter, type RouterHistory } from 'react-router-dom'; import { inject } from 'mobx-react'; -import { MoreIcon } from 'outline-icons'; -import NudeButton from 'components/NudeButton'; import CopyToClipboard from 'components/CopyToClipboard'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; import { documentHistoryUrl } from 'utils/routeHelpers'; @@ -42,16 +40,7 @@ class RevisionMenu extends React.Component { )}`; return ( - - - - } - onOpen={onOpen} - onClose={onClose} - className={className} - > + Restore version diff --git a/app/menus/ShareMenu.js b/app/menus/ShareMenu.js index 808cf1a6..5da87220 100644 --- a/app/menus/ShareMenu.js +++ b/app/menus/ShareMenu.js @@ -3,9 +3,7 @@ import * as React from 'react'; import { Redirect } from 'react-router-dom'; import { inject, observer } from 'mobx-react'; import { observable } from 'mobx'; -import { MoreIcon } from 'outline-icons'; -import NudeButton from 'components/NudeButton'; import CopyToClipboard from 'components/CopyToClipboard'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; import SharesStore from 'stores/SharesStore'; @@ -49,15 +47,7 @@ class ShareMenu extends React.Component { const { share, onOpen, onClose } = this.props; return ( - - - - } - onOpen={onOpen} - onClose={onClose} - > + Copy link diff --git a/app/menus/UserMenu.js b/app/menus/UserMenu.js index 83005bb3..0336cd8a 100644 --- a/app/menus/UserMenu.js +++ b/app/menus/UserMenu.js @@ -1,10 +1,8 @@ // @flow import * as React from 'react'; import { inject, observer } from 'mobx-react'; -import { MoreIcon } from 'outline-icons'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; -import NudeButton from 'components/NudeButton'; import UsersStore from 'stores/UsersStore'; import User from 'models/User'; @@ -62,13 +60,7 @@ class UserMenu extends React.Component { const { user } = this.props; return ( - - - - } - > + {!user.isSuspended && (user.isAdmin ? ( diff --git a/app/models/Collection.js b/app/models/Collection.js index 1f2201df..8cc5b7d1 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -1,17 +1,14 @@ // @flow -import invariant from 'invariant'; -import { map, without, pick, filter } from 'lodash'; +import { pick } 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 { @observable isSaving: boolean; @observable isLoadingUsers: boolean; - @observable userIds: string[] = []; id: string; name: string; @@ -48,45 +45,6 @@ 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) => diff --git a/app/models/Document.js b/app/models/Document.js index e22781f9..9ea25933 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -102,7 +102,9 @@ export default class Document extends BaseModel { pin = async () => { this.pinned = true; try { - await this.store.pin(this); + const res = await this.store.pin(this); + invariant(res && res.data, 'Data should be available'); + this.updateFromJson(res.data); } catch (err) { this.pinned = false; throw err; @@ -113,7 +115,9 @@ export default class Document extends BaseModel { unpin = async () => { this.pinned = false; try { - await this.store.unpin(this); + const res = await this.store.unpin(this); + invariant(res && res.data, 'Data should be available'); + this.updateFromJson(res.data); } catch (err) { this.pinned = true; throw err; @@ -147,7 +151,6 @@ export default class Document extends BaseModel { if (this.isSaving) return this; const isCreating = !this.id; - const wasDraft = !this.publishedAt; this.isSaving = true; this.updateTitle(); @@ -170,11 +173,6 @@ export default class Document extends BaseModel { ...options, }); } finally { - if (wasDraft && options.publish) { - this.store.rootStore.collections.fetch(this.collectionId, { - force: true, - }); - } this.isSaving = false; } }; diff --git a/app/models/Membership.js b/app/models/Membership.js new file mode 100644 index 00000000..0aac3f90 --- /dev/null +++ b/app/models/Membership.js @@ -0,0 +1,22 @@ +// @flow +import { computed } from 'mobx'; +import BaseModel from './BaseModel'; + +class Membership extends BaseModel { + id: string; + userId: string; + collectionId: string; + permission: string; + + @computed + get isEditor(): boolean { + return this.permission === 'read_write'; + } + + @computed + get isMaintainer(): boolean { + return this.permission === 'maintainer'; + } +} + +export default Membership; diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index 605fa3ff..b215c241 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -17,10 +17,12 @@ import RichMarkdownEditor from 'rich-markdown-editor'; import { newDocumentUrl, collectionUrl } from 'utils/routeHelpers'; import CollectionsStore from 'stores/CollectionsStore'; import DocumentsStore from 'stores/DocumentsStore'; +import PoliciesStore from 'stores/PoliciesStore'; import UiStore from 'stores/UiStore'; import Collection from 'models/Collection'; import Search from 'scenes/Search'; +import CollectionEdit from 'scenes/CollectionEdit'; import CollectionMenu from 'menus/CollectionMenu'; import Actions, { Action, Separator } from 'components/Actions'; import Heading from 'components/Heading'; @@ -35,7 +37,7 @@ 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'; +import CollectionMembers from 'scenes/CollectionMembers'; import Tabs from 'components/Tabs'; import Tab from 'components/Tab'; import PaginatedDocumentList from 'components/PaginatedDocumentList'; @@ -44,6 +46,7 @@ type Props = { ui: UiStore, documents: DocumentsStore, collections: CollectionsStore, + policies: PoliciesStore, match: Object, theme: Object, }; @@ -53,6 +56,7 @@ class CollectionScene extends React.Component { @observable collection: ?Collection; @observable isFetching: boolean = true; @observable permissionsModalOpen: boolean = false; + @observable editModalOpen: boolean = false; @observable redirectTo: ?string; componentDidMount() { @@ -77,7 +81,7 @@ class CollectionScene extends React.Component { this.collection = collection; await this.props.documents.fetchPinned({ - collection: id, + collectionId: id, }); } @@ -101,22 +105,36 @@ class CollectionScene extends React.Component { this.permissionsModalOpen = false; }; + handleEditModalOpen = () => { + this.editModalOpen = true; + }; + + handleEditModalClose = () => { + this.editModalOpen = false; + }; + renderActions() { + const can = this.props.policies.abilities(this.props.match.params.id); + return ( - - - - - - + {can.update && ( + + + + + + + + + )} @@ -155,18 +173,29 @@ class CollectionScene extends React.Component {    {collection.private && ( )} - + + + @@ -304,6 +333,6 @@ const Wrapper = styled(Flex)` margin: 10px 0; `; -export default inject('collections', 'documents', 'ui')( +export default inject('collections', 'policies', 'documents', 'ui')( withTheme(CollectionScene) ); diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js index 8d43f67a..a0df8037 100644 --- a/app/scenes/CollectionEdit.js +++ b/app/scenes/CollectionEdit.js @@ -1,11 +1,11 @@ // @flow import * as React from 'react'; -import { withRouter } from 'react-router-dom'; import { observable } from 'mobx'; import { inject, observer } from 'mobx-react'; import Input from 'components/Input'; import InputRich from 'components/InputRich'; import Button from 'components/Button'; +import Switch from 'components/Switch'; import Flex from 'shared/components/Flex'; import HelpText from 'components/HelpText'; import ColorPicker from 'components/ColorPicker'; @@ -13,7 +13,6 @@ import Collection from 'models/Collection'; import UiStore from 'stores/UiStore'; type Props = { - history: Object, collection: Collection, ui: UiStore, onSubmit: () => void, @@ -25,11 +24,13 @@ class CollectionEdit extends React.Component { @observable description: string = ''; @observable color: string = '#4E5C6E'; @observable isSaving: boolean; + @observable private: boolean = false; componentWillMount() { this.name = this.props.collection.name; this.description = this.props.collection.description; this.color = this.props.collection.color; + this.private = this.props.collection.private; } handleSubmit = async (ev: SyntheticEvent<*>) => { @@ -41,8 +42,10 @@ class CollectionEdit extends React.Component { name: this.name, description: this.description, color: this.color, + private: this.private, }); this.props.onSubmit(); + this.props.ui.showToast('The collection was updated'); } catch (err) { this.props.ui.showToast(err.message); } finally { @@ -62,6 +65,10 @@ class CollectionEdit extends React.Component { this.color = color; }; + handlePrivateChange = (ev: SyntheticInputEvent<*>) => { + this.private = ev.target.checked; + }; + render() { return ( @@ -91,6 +98,15 @@ class CollectionEdit extends React.Component { minHeight={68} maxHeight={200} /> + + + A private collection will only be visible to invited team members. + + +
+ ) : ( + + The {collection.name} collection is accessible by + everyone on the team. If you want to limit who can view the + collection,{' '} + + make it private + . + + )} + + Members + ( + this.handleRemoveUser(item)} + onUpdate={permission => this.handleUpdateUser(item, permission)} + /> + )} + /> + + + + + ); + } +} + +export default inject('auth', 'users', 'memberships', 'ui')(CollectionMembers); diff --git a/app/scenes/CollectionMembers/components/MemberListItem.js b/app/scenes/CollectionMembers/components/MemberListItem.js new file mode 100644 index 00000000..38af5fdf --- /dev/null +++ b/app/scenes/CollectionMembers/components/MemberListItem.js @@ -0,0 +1,82 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import Avatar from 'components/Avatar'; +import Flex from 'shared/components/Flex'; +import Time from 'shared/components/Time'; +import Badge from 'components/Badge'; +import Button from 'components/Button'; +import InputSelect from 'components/InputSelect'; +import ListItem from 'components/List/Item'; +import User from 'models/User'; +import Membership from 'models/Membership'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +const PERMISSIONS = [ + { label: 'Read only', value: 'read' }, + { label: 'Read & Edit', value: 'read_write' }, +]; +type Props = { + user: User, + membership?: ?Membership, + canEdit: boolean, + onAdd?: () => void, + onRemove?: () => void, + onUpdate?: (permission: string) => void, +}; + +const MemberListItem = ({ + user, + membership, + onRemove, + onUpdate, + onAdd, + canEdit, +}: Props) => { + return ( + + Joined