diff --git a/Makefile b/Makefile index 5080bbd8b..6b4bb0bab 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,11 @@ build: test: docker-compose run --rm outline yarn test +watch: + docker-compose run --rm outline yarn test:watch + destroy: docker-compose stop docker-compose rm -f -.PHONY: up build destroy # let's go to reserve rules names +.PHONY: up build destroy test watch # let's go to reserve rules names diff --git a/README.md b/README.md index 2fe74c6ba..3cdacf430 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Outline is still built and maintained by a small team – we'd love your help to However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster! -If you're looking for ways to get started, here's a list of ways to help us improve Outline: +If you’re looking for ways to get started, here's a list of ways to help us improve Outline: * Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label * Performance improvements, both on server and frontend diff --git a/app/components/Auth.js b/app/components/Auth.js index b3cd00296..6a9d3f867 100644 --- a/app/components/Auth.js +++ b/app/components/Auth.js @@ -4,7 +4,6 @@ import { Provider } from 'mobx-react'; import stores from 'stores'; import ApiKeysStore from 'stores/ApiKeysStore'; import UsersStore from 'stores/UsersStore'; -import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; import IntegrationsStore from 'stores/IntegrationsStore'; import CacheStore from 'stores/CacheStore'; @@ -27,10 +26,6 @@ const Auth = ({ children }: Props) => { integrations: new IntegrationsStore(), apiKeys: new ApiKeysStore(), users: new UsersStore(), - documents: new DocumentsStore({ - ui: stores.ui, - cache, - }), collections: new CollectionsStore({ ui: stores.ui, teamId: team.id, diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js index a71e4bf32..c192e28d1 100644 --- a/app/components/Avatar/Avatar.js +++ b/app/components/Avatar/Avatar.js @@ -6,10 +6,19 @@ import { observer } from 'mobx-react'; import { color } from 'shared/styles/constants'; import placeholder from './placeholder.png'; +type Props = { + src: string, + size: number, +}; + @observer -class Avatar extends React.Component<*> { +class Avatar extends React.Component { @observable error: boolean; + static defaultProps = { + size: 24, + }; + handleError = () => { this.error = true; }; @@ -17,7 +26,7 @@ class Avatar extends React.Component<*> { render() { return ( @@ -26,8 +35,8 @@ class Avatar extends React.Component<*> { } const CircleImg = styled.img` - width: 24px; - height: 24px; + width: ${props => props.size}px; + height: ${props => props.size}px; border-radius: 50%; border: 2px solid ${color.white}; flex-shrink: 0; diff --git a/app/components/Button/Button.js b/app/components/Button/Button.js index 7ddaafd70..5c89e8f34 100644 --- a/app/components/Button/Button.js +++ b/app/components/Button/Button.js @@ -2,7 +2,7 @@ import * as React from 'react'; import styled from 'styled-components'; import { color } from 'shared/styles/constants'; -import { darken, lighten } from 'polished'; +import { darken } from 'polished'; const RealButton = styled.button` display: inline-block; @@ -40,11 +40,14 @@ const RealButton = styled.button` ${props => props.light && ` - color: ${color.text}; - background: ${lighten(0.08, color.slateLight)}; + color: ${color.slate}; + background: transparent; + border: 1px solid ${color.slate}; &:hover { - background: ${color.slateLight}; + background: transparent; + color: ${color.slateDark}; + border: 1px solid ${color.slateDark}; } `} ${props => props.neutral && diff --git a/app/components/CopyToClipboard/CopyToClipboard.js b/app/components/CopyToClipboard/CopyToClipboard.js index a1deca607..cf762a048 100644 --- a/app/components/CopyToClipboard/CopyToClipboard.js +++ b/app/components/CopyToClipboard/CopyToClipboard.js @@ -5,8 +5,8 @@ import copy from 'copy-to-clipboard'; type Props = { text: string, children?: React.Node, - onClick?: () => void, - onCopy: () => void, + onClick?: () => *, + onCopy: () => *, }; class CopyToClipboard extends React.PureComponent { diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index d6026c353..e5c303e7d 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -112,7 +112,14 @@ class DocumentPreview extends React.Component { } = this.props; return ( - + {document.publishedAt && ( diff --git a/app/components/List/Item.js b/app/components/List/Item.js new file mode 100644 index 000000000..4f19f0e52 --- /dev/null +++ b/app/components/List/Item.js @@ -0,0 +1,57 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { color, fontSize } from 'shared/styles/constants'; + +type Props = { + image?: React.Node, + title: string, + subtitle: React.Node, + actions?: React.Node, +}; + +const ListItem = ({ image, title, subtitle, actions }: Props) => { + return ( + + {image && {image}} + + {title} + {subtitle} + + {actions && {actions}} + + ); +}; + +const Wrapper = styled.li` + display: flex; + padding: 12px 0; + margin: 0; + border-bottom: 1px solid ${color.smokeDark}; +`; + +const Image = styled.div` + padding: 0 8px 0 0; + max-height: 40px; +`; + +const Heading = styled.h2` + font-size: ${fontSize.medium}; + margin: 0; +`; + +const Content = styled.div` + flex-grow: 1; +`; + +const Subtitle = styled.p` + margin: 0; + font-size: ${fontSize.small}; + color: ${color.slate}; +`; + +const Actions = styled.div` + align-self: center; +`; + +export default ListItem; diff --git a/app/components/List/List.js b/app/components/List/List.js new file mode 100644 index 000000000..892580d6b --- /dev/null +++ b/app/components/List/List.js @@ -0,0 +1,10 @@ +// @flow +import styled from 'styled-components'; + +const List = styled.ol` + margin: 0; + padding: 0; + list-style: none; +`; + +export default List; diff --git a/app/components/List/index.js b/app/components/List/index.js new file mode 100644 index 000000000..01a191572 --- /dev/null +++ b/app/components/List/index.js @@ -0,0 +1,3 @@ +// @flow +import List from './List'; +export default List; diff --git a/app/components/Modals/Modals.js b/app/components/Modals/Modals.js index 81506c74d..d9a2c23c2 100644 --- a/app/components/Modals/Modals.js +++ b/app/components/Modals/Modals.js @@ -7,6 +7,7 @@ import CollectionNew from 'scenes/CollectionNew'; import CollectionEdit from 'scenes/CollectionEdit'; import CollectionDelete from 'scenes/CollectionDelete'; import DocumentDelete from 'scenes/DocumentDelete'; +import DocumentShare from 'scenes/DocumentShare'; import KeyboardShortcuts from 'scenes/KeyboardShortcuts'; type Props = { @@ -44,6 +45,9 @@ class Modals extends React.Component { + + + diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 8c6db43da..16fe19602 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -31,10 +31,6 @@ class MainSidebar extends React.Component { this.props.ui.setActiveModal('collection-new'); }; - handleEditCollection = () => { - this.props.ui.setActiveModal('collection-edit'); - }; - render() { const { auth, documents } = this.props; const { user, team } = auth; diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index db92804b7..2f20c6d70 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -1,7 +1,13 @@ // @flow import * as React from 'react'; import { observer, inject } from 'mobx-react'; -import { ProfileIcon, SettingsIcon, CodeIcon, UserIcon } from 'outline-icons'; +import { + ProfileIcon, + SettingsIcon, + CodeIcon, + UserIcon, + LinkIcon, +} from 'outline-icons'; import Flex from 'shared/components/Flex'; import Sidebar, { Section } from './Sidebar'; @@ -48,8 +54,11 @@ class SettingsSidebar extends React.Component {
Team
- }> - Users + }> + Members + + }> + Share Links { activeClassName="activeDropZone" > ) => *, children?: React.Node, icon?: React.Node, diff --git a/app/index.js b/app/index.js index bb84a01c9..f90af9838 100644 --- a/app/index.js +++ b/app/index.js @@ -21,8 +21,9 @@ import Collection from 'scenes/Collection'; import Document from 'scenes/Document'; import Search from 'scenes/Search'; import Settings from 'scenes/Settings'; -import Users from 'scenes/Settings/Users'; +import Members from 'scenes/Settings/Members'; import Slack from 'scenes/Settings/Slack'; +import Shares from 'scenes/Settings/Shares'; import Tokens from 'scenes/Settings/Tokens'; import SlackAuth from 'scenes/SlackAuth'; import ErrorAuth from 'scenes/ErrorAuth'; @@ -68,6 +69,7 @@ if (element) { /> + @@ -75,7 +77,12 @@ if (element) { - + + { this.props.document.download(); }; + handleShareLink = async (ev: SyntheticEvent<*>) => { + const { document } = this.props; + if (!document.shareUrl) await document.share(); + + this.props.ui.setActiveModal('document-share', { document }); + }; + render() { const { document, label, className, showPrint } = this.props; const isDraft = !document.publishedAt; @@ -80,6 +87,12 @@ class DocumentMenu extends React.Component { Star )} + + Share link +
*, + onClose?: () => *, + history: Object, + shares: SharesStore, + share: Share, +}; + +class ShareMenu extends React.Component { + onGoToDocument = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.props.history.push(this.props.share.documentUrl); + }; + + onRevoke = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.props.shares.revoke(this.props.share); + }; + + render() { + const { share, label, onOpen, onClose } = this.props; + + return ( + } + onOpen={onOpen} + onClose={onClose} + > + + Copy link + + + Go to document + +
+ Revoke link +
+ ); + } +} + +export default withRouter(inject('shares')(ShareMenu)); diff --git a/app/scenes/Settings/components/UserMenu.js b/app/menus/UserMenu.js similarity index 69% rename from app/scenes/Settings/components/UserMenu.js rename to app/menus/UserMenu.js index f833f921f..a3b90f354 100644 --- a/app/scenes/Settings/components/UserMenu.js +++ b/app/menus/UserMenu.js @@ -61,29 +61,27 @@ class UserMenu extends React.Component { const { user } = this.props; return ( - - }> - {!user.isSuspended && - (user.isAdmin ? ( - - Make {user.name} a member… - - ) : ( - - Make {user.name} an admin… - - ))} - {user.isSuspended ? ( - - Activate account + }> + {!user.isSuspended && + (user.isAdmin ? ( + + Make {user.name} a member… ) : ( - - Suspend account… + + Make {user.name} an admin… - )} - - + ))} + {user.isSuspended ? ( + + Activate account + + ) : ( + + Suspend account… + + )} + ); } } diff --git a/app/models/Document.js b/app/models/Document.js index aa317dc3c..ba17afbe6 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -32,7 +32,6 @@ class Document extends BaseModel { id: string; team: string; emoji: string; - private: boolean = false; starred: boolean = false; pinned: boolean = false; text: string = ''; @@ -40,11 +39,10 @@ class Document extends BaseModel { parentDocument: ?string; publishedAt: ?string; url: string; + shareUrl: ?string; views: number; revision: number; - data: Object; - /* Computed */ @computed @@ -101,6 +99,18 @@ class Document extends BaseModel { /* Actions */ + @action + share = async () => { + try { + const res = await client.post('/shares.create', { documentId: this.id }); + invariant(res && res.data, 'Document API response should be available'); + + this.shareUrl = res.data.url; + } catch (e) { + this.errors.add('Document failed to share'); + } + }; + @action pin = async () => { this.pinned = true; @@ -277,7 +287,6 @@ class Document extends BaseModel { data.emoji = emoji; } if (dirty) this.hasPendingChanges = true; - this.data = data; extendObservable(this, data); } diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index 3d952d64a..534f04c7b 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -1,6 +1,5 @@ // @flow import * as React from 'react'; -import get from 'lodash/get'; import debounce from 'lodash/debounce'; import styled from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; @@ -25,13 +24,14 @@ import Document from 'models/Document'; import Actions from './components/Actions'; import DocumentMove from './components/DocumentMove'; import UiStore from 'stores/UiStore'; +import AuthStore from 'stores/AuthStore'; import DocumentsStore from 'stores/DocumentsStore'; -import CollectionsStore from 'stores/CollectionsStore'; import LoadingPlaceholder from 'components/LoadingPlaceholder'; import LoadingIndicator from 'components/LoadingIndicator'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import Search from 'scenes/Search'; +import Error404 from 'scenes/Error404'; const AUTOSAVE_INTERVAL = 3000; const DISCARD_CHANGES = ` @@ -44,8 +44,8 @@ type Props = { history: Object, location: Location, documents: DocumentsStore, - collections: CollectionsStore, newDocument?: boolean, + auth: AuthStore, ui: UiStore, }; @@ -55,6 +55,7 @@ class DocumentScene extends React.Component { @observable editorComponent; @observable editCache: ?string; + @observable document: ?Document; @observable newDocument: ?Document; @observable isLoading = false; @observable isSaving = false; @@ -90,7 +91,7 @@ class DocumentScene extends React.Component { loadDocument = async props => { if (props.newDocument) { - const newDocument = new Document({ + this.document = new Document({ collection: { id: props.match.params.id }, parentDocument: new URLSearchParams(props.location.search).get( 'parentDocument' @@ -98,32 +99,30 @@ class DocumentScene extends React.Component { title: '', text: '', }); - this.newDocument = newDocument; } else { - let document = this.getDocument(props.match.params.documentSlug); + this.document = await this.props.documents.fetch( + props.match.params.documentSlug, + { shareId: props.match.params.shareId } + ); - if (document) { - this.props.documents.fetch(props.match.params.documentSlug); - this.props.ui.setActiveDocument(document); - } else { - document = await this.props.documents.fetch( - props.match.params.documentSlug - ); - } + const document = this.document; if (document) { this.props.ui.setActiveDocument(document); // Cache data if user enters edit mode and cancels this.editCache = document.text; - if (!this.isEditing && document.publishedAt) { - document.view(); - } - // Update url to match the current one - this.props.history.replace( - updateDocumentUrl(props.match.url, document.url) - ); + if (this.props.auth.user) { + if (!this.isEditing && document.publishedAt) { + document.view(); + } + + // Update url to match the current one + this.props.history.replace( + updateDocumentUrl(props.match.url, document.url) + ); + } } else { // Render 404 with search this.notFound = true; @@ -137,22 +136,14 @@ class DocumentScene extends React.Component { }; get isEditing() { + const document = this.document; + return !!( - this.props.match.path === matchDocumentEdit || this.props.newDocument + this.props.match.path === matchDocumentEdit || + (document && !document.id) ); } - getDocument(documentSlug: ?string) { - if (this.newDocument) return this.newDocument; - return this.props.documents.getByUrl( - `/doc/${documentSlug || this.props.match.params.documentSlug}` - ); - } - - get document() { - return this.getDocument(); - } - handleCloseMoveModal = () => (this.moveModalOpen = false); handleOpenMoveModal = () => (this.moveModalOpen = true); @@ -162,6 +153,7 @@ class DocumentScene extends React.Component { let document = this.document; if (!document || !document.allowSave) return; + let isNew = !document.id; this.editCache = null; this.isSaving = true; this.isPublishing = !!options.publish; @@ -172,7 +164,7 @@ class DocumentScene extends React.Component { if (options.done) { this.props.history.push(document.url); this.props.ui.setActiveDocument(document); - } else if (this.props.newDocument) { + } else if (isNew) { this.props.history.push(documentEditUrl(document)); this.props.ui.setActiveDocument(document); } @@ -237,19 +229,20 @@ class DocumentScene extends React.Component { }; render() { + const { location, match } = this.props; const Editor = this.editorComponent; - const isMoving = this.props.match.path === matchDocumentMove; + const isMoving = match.path === matchDocumentMove; const document = this.document; - const titleText = - get(document, 'title', '') || - this.props.collections.titleForDocument(this.props.location.pathname); + const titleFromState = location.state ? location.state.title : ''; + const titleText = document ? document.title : titleFromState; + const isShare = match.params.shareId; if (this.notFound) { - return ; + return isShare ? : ; } return ( - + {isMoving && document && } {titleText && } {(this.isLoading || this.isSaving) && } @@ -282,19 +275,20 @@ class DocumentScene extends React.Component { readOnly={!this.isEditing} /> - {document && ( - - )} + {document && + !isShare && ( + + )} )} @@ -322,6 +316,4 @@ const LoadingState = styled(LoadingPlaceholder)` margin: 90px 0; `; -export default withRouter( - inject('ui', 'user', 'documents', 'collections')(DocumentScene) -); +export default withRouter(inject('ui', 'auth', 'documents')(DocumentScene)); diff --git a/app/scenes/DocumentShare/DocumentShare.js b/app/scenes/DocumentShare/DocumentShare.js new file mode 100644 index 000000000..025e42580 --- /dev/null +++ b/app/scenes/DocumentShare/DocumentShare.js @@ -0,0 +1,68 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { Link } from 'react-router-dom'; +import Input from 'components/Input'; +import Button from 'components/Button'; +import CopyToClipboard from 'components/CopyToClipboard'; +import HelpText from 'components/HelpText'; +import Document from 'models/Document'; + +type Props = { + document?: Document, + onSubmit: () => *, +}; + +@observer +class DocumentShare extends React.Component { + @observable isCopied: boolean; + timeout: TimeoutID; + + componentWillUnmount() { + clearTimeout(this.timeout); + } + + handleCopied = () => { + this.isCopied = true; + + this.timeout = setTimeout(() => { + this.isCopied = false; + this.props.onSubmit(); + }, 1500); + }; + + render() { + const { document, onSubmit } = this.props; + if (!document) return null; + + return ( +
+ + The link below allows anyone in the world to access a read-only + version of the document {document.title}. You can + revoke this link in settings at any time.{' '} + + Manage share links + . + + + + + +
+ ); + } +} + +export default DocumentShare; diff --git a/app/scenes/DocumentShare/index.js b/app/scenes/DocumentShare/index.js new file mode 100644 index 000000000..c480add32 --- /dev/null +++ b/app/scenes/DocumentShare/index.js @@ -0,0 +1,3 @@ +// @flow +import DocumentShare from './DocumentShare'; +export default DocumentShare; diff --git a/app/scenes/Error404/Error404.js b/app/scenes/Error404/Error404.js index 041767bd2..dfab52b8c 100644 --- a/app/scenes/Error404/Error404.js +++ b/app/scenes/Error404/Error404.js @@ -1,25 +1,19 @@ // @flow import * as React from 'react'; -import { Link } from 'react-router-dom'; - import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; -class Error404 extends React.Component<*> { - render() { - return ( - - -

Not Found

- -

We're unable to find the page you're accessing.

- -

- Maybe you want to try search instead? -

-
- ); - } -} +const Error404 = () => { + return ( + + +

Not Found

+

We were unable to find the page you’re looking for.

+

+ Go to homepage. +

+
+ ); +}; export default Error404; diff --git a/app/scenes/Search/Search.js b/app/scenes/Search/Search.js index 3e823117d..e4a031ba4 100644 --- a/app/scenes/Search/Search.js +++ b/app/scenes/Search/Search.js @@ -121,7 +121,7 @@ class Search extends React.Component { @action loadMoreResults = async () => { - // Don't paginate if there aren't more results or we're in the middle of fetching + // Don't paginate if there aren't more results or we’re in the middle of fetching if (!this.allowLoadMore || this.isFetching) return; // Fetch more results diff --git a/app/scenes/Settings/Members.js b/app/scenes/Settings/Members.js new file mode 100644 index 000000000..2155b73b1 --- /dev/null +++ b/app/scenes/Settings/Members.js @@ -0,0 +1,48 @@ +// @flow +import * as React from 'react'; +import invariant from 'invariant'; +import { observer, inject } from 'mobx-react'; + +import AuthStore from 'stores/AuthStore'; +import UsersStore from 'stores/UsersStore'; +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; +import UserListItem from './components/UserListItem'; +import List from 'components/List'; + +type Props = { + auth: AuthStore, + users: UsersStore, +}; + +@observer +class Members extends React.Component { + componentDidMount() { + this.props.users.fetchPage({ limit: 100 }); + } + + render() { + const { users, auth } = this.props; + const currentUser = auth.user; + invariant(currentUser, 'User should exist'); + + return ( + + +

Members

+ + + {users.data.map(user => ( + + ))} + +
+ ); + } +} + +export default inject('auth', 'users')(Members); diff --git a/app/scenes/Settings/Shares.js b/app/scenes/Settings/Shares.js new file mode 100644 index 000000000..28139353c --- /dev/null +++ b/app/scenes/Settings/Shares.js @@ -0,0 +1,38 @@ +// @flow +import * as React from 'react'; +import { observer, inject } from 'mobx-react'; +import SharesStore from 'stores/SharesStore'; + +import ShareListItem from './components/ShareListItem'; +import List from 'components/List'; +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; + +type Props = { + shares: SharesStore, +}; + +@observer +class Shares extends React.Component { + componentDidMount() { + this.props.shares.fetchPage({ limit: 100 }); + } + + render() { + const { shares } = this.props; + + return ( + + +

Share Links

+ + {shares.orderedData.map(share => ( + + ))} + +
+ ); + } +} + +export default inject('shares')(Shares); diff --git a/app/scenes/Settings/Tokens.js b/app/scenes/Settings/Tokens.js index a3b30afa1..3379b2cb6 100644 --- a/app/scenes/Settings/Tokens.js +++ b/app/scenes/Settings/Tokens.js @@ -3,17 +3,15 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; -import ApiToken from './components/ApiToken'; import ApiKeysStore from 'stores/ApiKeysStore'; -import { color } from 'shared/styles/constants'; import Button from 'components/Button'; import Input from 'components/Input'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import HelpText from 'components/HelpText'; -import Subheading from 'components/Subheading'; +import List from 'components/List'; +import TokenListItem from './components/TokenListItem'; type Props = { apiKeys: ApiKeysStore, @@ -46,29 +44,23 @@ class Tokens extends React.Component {

API Tokens

- {hasApiKeys && [ - Your tokens, - - - {apiKeys.data.map(key => ( - - ))} - -
, - Create a token, - ]} - You can create unlimited personal API tokens to hack on your wiki. Learn more in the API documentation. + {hasApiKeys && ( + + {apiKeys.data.map(token => ( + + ))} + + )} +
{ } } -const Table = styled.table` - margin-bottom: 30px; - width: 100%; - - td { - margin-right: 20px; - color: ${color.slate}; - } -`; - export default inject('apiKeys')(Tokens); diff --git a/app/scenes/Settings/Users.js b/app/scenes/Settings/Users.js deleted file mode 100644 index e17b7cae1..000000000 --- a/app/scenes/Settings/Users.js +++ /dev/null @@ -1,108 +0,0 @@ -// @flow -import * as React from 'react'; -import invariant from 'invariant'; -import { observer, inject } from 'mobx-react'; -import styled from 'styled-components'; -import Flex from 'shared/components/Flex'; -import Avatar from 'components/Avatar'; -import { color } from 'shared/styles/constants'; - -import AuthStore from 'stores/AuthStore'; -import ErrorsStore from 'stores/ErrorsStore'; -import UsersStore from 'stores/UsersStore'; -import CenteredContent from 'components/CenteredContent'; -import LoadingPlaceholder from 'components/LoadingPlaceholder'; -import PageTitle from 'components/PageTitle'; -import UserMenu from './components/UserMenu'; - -type Props = { - auth: AuthStore, - errors: ErrorsStore, - users: UsersStore, -}; - -@observer -class Users extends React.Component { - componentDidMount() { - this.props.users.fetchPage({ limit: 100 }); - } - - render() { - const currentUser = this.props.auth.user; - invariant(currentUser, 'User should exist'); - - return ( - - -

Users

- - {!this.props.users.isLoaded ? ( - - {this.props.users.data && ( - - {this.props.users.data.map(user => ( - - - - - {user.name} {user.email && `(${user.email})`} - {user.isAdmin && ( - Admin - )} - {user.isSuspended && Suspended} - - - - {currentUser.id !== user.id && } - - - ))} - - )} - - ) : ( - - )} -
- ); - } -} - -const UserList = styled(Flex)` - border: 1px solid ${color.smoke}; - border-radius: 4px; - - margin-top: 20px; - margin-bottom: 40px; -`; - -const User = styled(Flex)` - padding: 10px; - border-bottom: 1px solid ${color.smoke}; - font-size: 15px; - - &:last-child { - border-bottom: none; - } -`; - -const UserDetails = styled(Flex)` - opacity: ${({ suspended }) => (suspended ? 0.5 : 1)}; -`; - -const UserName = styled.span` - padding-left: 8px; -`; - -const Badge = styled.span` - margin-left: 10px; - padding: 2px 6px 3px; - background-color: ${({ admin }) => (admin ? color.primary : color.smokeDark)}; - color: ${({ admin }) => (admin ? color.white : color.text)}; - border-radius: 2px; - font-size: 11px; - text-transform: uppercase; - font-weight: normal; -`; - -export default inject('auth', 'errors', 'users')(Users); diff --git a/app/scenes/Settings/components/ApiToken.js b/app/scenes/Settings/components/ApiToken.js deleted file mode 100644 index 491a90364..000000000 --- a/app/scenes/Settings/components/ApiToken.js +++ /dev/null @@ -1,43 +0,0 @@ -// @flow -import * as React from 'react'; -import { observable } from 'mobx'; -import { observer } from 'mobx-react'; -import Button from 'components/Button'; - -type Props = { - id: string, - name: ?string, - secret: string, - onDelete: (id: string) => *, -}; - -@observer -class ApiToken extends React.Component { - @observable disabled: boolean; - - onClick = () => { - this.props.onDelete(this.props.id); - this.disabled = true; - }; - - render() { - const { name, secret } = this.props; - const { disabled } = this; - - return ( - - {name} - - {secret} - - - - - - ); - } -} - -export default ApiToken; diff --git a/app/scenes/Settings/components/ShareListItem.js b/app/scenes/Settings/components/ShareListItem.js new file mode 100644 index 000000000..c78b950c1 --- /dev/null +++ b/app/scenes/Settings/components/ShareListItem.js @@ -0,0 +1,31 @@ +// @flow +import * as React from 'react'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import ShareMenu from 'menus/ShareMenu'; +import ListItem from 'components/List/Item'; +import type { Share } from '../../../types'; + +type Props = { + share: Share, +}; + +const ShareListItem = ({ share }: Props) => { + return ( + + Shared{' '} + {' '} + ago by {share.createdBy.name} + + } + actions={} + /> + ); +}; + +export default ShareListItem; diff --git a/app/scenes/Settings/components/TokenListItem.js b/app/scenes/Settings/components/TokenListItem.js new file mode 100644 index 000000000..d4ad90d70 --- /dev/null +++ b/app/scenes/Settings/components/TokenListItem.js @@ -0,0 +1,27 @@ +// @flow +import * as React from 'react'; +import Button from 'components/Button'; +import ListItem from 'components/List/Item'; +import type { ApiKey } from '../../../types'; + +type Props = { + token: ApiKey, + onDelete: (tokenId: string) => *, +}; + +const TokenListItem = ({ token, onDelete }: Props) => { + return ( + {token.secret}} + actions={ + + } + /> + ); +}; + +export default TokenListItem; diff --git a/app/scenes/Settings/components/UserListItem.js b/app/scenes/Settings/components/UserListItem.js new file mode 100644 index 000000000..c7de8a56b --- /dev/null +++ b/app/scenes/Settings/components/UserListItem.js @@ -0,0 +1,45 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { color } from 'shared/styles/constants'; + +import UserMenu from 'menus/UserMenu'; +import Avatar from 'components/Avatar'; +import ListItem from 'components/List/Item'; +import type { User } from '../../../types'; + +type Props = { + user: User, + isCurrentUser: boolean, +}; + +const UserListItem = ({ user, isCurrentUser }: Props) => { + return ( + } + subtitle={ + + {user.username ? user.username : user.email} + {user.isAdmin && Admin} + {user.isSuspended && Suspended} + + } + actions={isCurrentUser ? undefined : } + /> + ); +}; + +const Badge = styled.span` + margin-left: 10px; + padding: 2px 6px 3px; + background-color: ${({ admin }) => (admin ? color.primary : color.smokeDark)}; + color: ${({ admin }) => (admin ? color.white : color.text)}; + border-radius: 2px; + font-size: 11px; + text-transform: uppercase; + font-weight: normal; +`; + +export default UserListItem; diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index d161deaff..08ce80044 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -1,30 +1,25 @@ // @flow -import { - observable, - action, - computed, - ObservableMap, - runInAction, - autorunAsync, -} from 'mobx'; +import { observable, action, computed, ObservableMap, runInAction } from 'mobx'; import { client } from 'utils/ApiClient'; import _ from 'lodash'; import invariant from 'invariant'; import BaseStore from 'stores/BaseStore'; -import stores from 'stores'; import Document from 'models/Document'; import ErrorsStore from 'stores/ErrorsStore'; -import CacheStore from 'stores/CacheStore'; import UiStore from 'stores/UiStore'; import type { PaginationParams } from 'types'; -const DOCUMENTS_CACHE_KEY = 'DOCUMENTS_CACHE_KEY'; export const DEFAULT_PAGINATION_LIMIT = 25; type Options = { - cache: CacheStore, ui: UiStore, + errors: ErrorsStore, +}; + +type FetchOptions = { + prefetch?: boolean, + shareId?: string, }; class DocumentsStore extends BaseStore { @@ -35,7 +30,6 @@ class DocumentsStore extends BaseStore { @observable isFetching: boolean = false; errors: ErrorsStore; - cache: CacheStore; ui: UiStore; /* Computed */ @@ -178,15 +172,23 @@ class DocumentsStore extends BaseStore { @action prefetchDocument = async (id: string) => { - if (!this.getById(id)) this.fetch(id, true); + if (!this.getById(id)) { + this.fetch(id, { prefetch: true }); + } }; @action - fetch = async (id: string, prefetch?: boolean): Promise<*> => { - if (!prefetch) this.isFetching = true; + fetch = async (id: string, options?: FetchOptions = {}): Promise<*> => { + if (!options.prefetch) this.isFetching = true; try { - const res = await client.post('/documents.info', { id }); + const doc = this.getById(id) || this.getByUrl(id); + if (doc) return doc; + + const res = await client.post('/documents.info', { + id, + shareId: options.shareId, + }); invariant(res && res.data, 'Document not available'); const { data } = res; const document = new Document(data); @@ -198,7 +200,7 @@ class DocumentsStore extends BaseStore { return document; } catch (e) { - this.errors.add('Failed to load documents'); + this.errors.add('Failed to load document'); } finally { this.isFetching = false; } @@ -228,16 +230,9 @@ class DocumentsStore extends BaseStore { constructor(options: Options) { super(); - this.errors = stores.errors; - this.cache = options.cache; + this.errors = options.errors; this.ui = options.ui; - this.cache.getItem(DOCUMENTS_CACHE_KEY).then(data => { - if (data) { - data.forEach(document => this.add(new Document(document))); - } - }); - this.on('documents.delete', (data: { id: string }) => { this.remove(data.id); }); @@ -254,15 +249,6 @@ class DocumentsStore extends BaseStore { this.fetchRecentlyModified(); this.fetchRecentlyViewed(); }); - - autorunAsync('DocumentsStore.persists', () => { - if (this.data.size) { - this.cache.setItem( - DOCUMENTS_CACHE_KEY, - Array.from(this.data.values()).map(collection => collection.data) - ); - } - }); } } diff --git a/app/stores/SharesStore.js b/app/stores/SharesStore.js new file mode 100644 index 000000000..dcabd8837 --- /dev/null +++ b/app/stores/SharesStore.js @@ -0,0 +1,51 @@ +// @flow +import _ from 'lodash'; +import { observable, action, runInAction, ObservableMap, computed } from 'mobx'; +import invariant from 'invariant'; +import { client } from 'utils/ApiClient'; +import type { Share, PaginationParams } from 'types'; + +class SharesStore { + @observable data: Map = new ObservableMap([]); + @observable isFetching: boolean = false; + @observable isSaving: boolean = false; + + @computed + get orderedData(): Share[] { + return _.sortBy(this.data.values(), 'createdAt').reverse(); + } + + @action + fetchPage = async (options: ?PaginationParams): Promise<*> => { + this.isFetching = true; + + try { + const res = await client.post('/shares.list', options); + invariant(res && res.data, 'Data should be available'); + const { data } = res; + + runInAction('fetchShares', () => { + data.forEach(share => { + this.data.set(share.id, share); + }); + }); + } catch (e) { + console.error('Something went wrong'); + } + this.isFetching = false; + }; + + @action + revoke = async (share: Share) => { + try { + await client.post('/shares.delete', { id: share.id }); + runInAction('revoke', () => { + this.data.delete(share.id); + }); + } catch (e) { + console.error('Something went wrong'); + } + }; +} + +export default SharesStore; diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index dda930c1e..109ee725e 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -30,7 +30,7 @@ class UiStore { this.activeDocumentId = document.id; if (document.publishedAt) { - this.activeCollectionId = document.collection.id; + this.activeCollectionId = document.collectionId; } }; diff --git a/app/stores/UsersStore.js b/app/stores/UsersStore.js index d9df0b832..5b4945dd7 100644 --- a/app/stores/UsersStore.js +++ b/app/stores/UsersStore.js @@ -6,7 +6,6 @@ import type { User, PaginationParams } from 'types'; class UsersStore { @observable data: User[] = []; - @observable isLoaded: boolean = false; @observable isSaving: boolean = false; @action @@ -22,7 +21,6 @@ class UsersStore { } catch (e) { console.error('Something went wrong'); } - this.isLoaded = false; }; @action diff --git a/app/stores/index.js b/app/stores/index.js index ccfaa9c8c..68d49ca9c 100644 --- a/app/stores/index.js +++ b/app/stores/index.js @@ -2,13 +2,18 @@ import AuthStore from './AuthStore'; import UiStore from './UiStore'; import ErrorsStore from './ErrorsStore'; +import DocumentsStore from './DocumentsStore'; +import SharesStore from './SharesStore'; +const ui = new UiStore(); +const errors = new ErrorsStore(); const stores = { user: null, // Including for Layout auth: new AuthStore(), - ui: new UiStore(), - errors: new ErrorsStore(), + ui, + errors, + documents: new DocumentsStore({ ui, errors }), + shares: new SharesStore(), }; -window.stores = stores; export default stores; diff --git a/app/types/index.js b/app/types/index.js index 5c5a4dad0..2ef769b57 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -9,6 +9,16 @@ export type User = { isSuspended?: boolean, }; +export type Share = { + id: string, + url: string, + documentTitle: string, + documentUrl: string, + createdBy: User, + createdAt: string, + updatedAt: string, +}; + export type Team = { id: string, name: string, @@ -29,7 +39,6 @@ export type Document = { createdBy: User, html: string, id: string, - private: boolean, starred: boolean, views: number, team: string, @@ -58,6 +67,6 @@ export type PaginationParams = { export type ApiKey = { id: string, - name: ?string, + name: string, secret: string, }; diff --git a/package.json b/package.json index d9cdff83b..45fa310d6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test": "npm run test:app && npm run test:server", "test:app": "jest", "test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit", + "test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll", "precommit": "lint-staged" }, "lint-staged": { diff --git a/server/api/__snapshots__/shares.test.js.snap b/server/api/__snapshots__/shares.test.js.snap new file mode 100644 index 000000000..77cc5d4df --- /dev/null +++ b/server/api/__snapshots__/shares.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#shares.create should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#shares.list should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/api/documents.js b/server/api/documents.js index 90feba8fb..5cf4a3b27 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -4,13 +4,13 @@ import Sequelize from 'sequelize'; import auth from './middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentDocument, presentRevision } from '../presenters'; -import { Document, Collection, Star, View, Revision } from '../models'; +import { Document, Collection, Share, Star, View, Revision } from '../models'; import { InvalidRequestError } from '../errors'; import events from '../events'; import policy from '../policies'; const Op = Sequelize.Op; -const { authorize } = policy; +const { authorize, cannot } = policy; const router = new Router(); router.post('documents.list', auth(), pagination(), async ctx => { @@ -157,15 +157,36 @@ router.post('documents.drafts', auth(), pagination(), async ctx => { }; }); -router.post('documents.info', auth(), async ctx => { - const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); - const document = await Document.findById(id); +router.post('documents.info', auth({ required: false }), async ctx => { + const { id, shareId } = ctx.body; + ctx.assertPresent(id || shareId, 'id or shareId is required'); - authorize(ctx.state.user, 'read', document); + const user = ctx.state.user; + let document; + + if (shareId) { + const share = await Share.findById(shareId, { + include: [ + { + model: Document, + required: true, + as: 'document', + }, + ], + }); + if (!share) { + throw new InvalidRequestError('Document could not be found for shareId'); + } + document = share.document; + } else { + document = await Document.findById(id); + authorize(user, 'read', document); + } + + const isPublic = cannot(user, 'read', document); ctx.body = { - data: await presentDocument(ctx, document), + data: await presentDocument(ctx, document, { isPublic }), }; }); diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 2dcc67e4e..770fc4555 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -3,7 +3,7 @@ import TestServer from 'fetch-test-server'; import app from '..'; import { Document, View, Star, Revision } from '../models'; import { flushdb, seed } from '../test/support'; -import { buildUser } from '../test/factories'; +import { buildShare, buildUser } from '../test/factories'; const server = new TestServer(app.callback()); @@ -35,6 +35,68 @@ describe('#documents.info', async () => { expect(res.status).toEqual(200); expect(body.data.id).toEqual(document.id); }); + + it('should return redacted documents from shareId without token', async () => { + const { document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: document.teamId, + }); + + const res = await server.post('/api/documents.info', { + body: { shareId: share.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(document.id); + expect(body.data.collection).toEqual(undefined); + expect(body.data.createdBy).toEqual(undefined); + expect(body.data.updatedBy).toEqual(undefined); + }); + + it('should return documents from shareId with token', async () => { + const { user, document, collection } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: document.teamId, + }); + + const res = await server.post('/api/documents.info', { + body: { token: user.getJwtToken(), shareId: share.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(document.id); + expect(body.data.collection.id).toEqual(collection.id); + expect(body.data.createdBy.id).toEqual(user.id); + expect(body.data.updatedBy.id).toEqual(user.id); + }); + + it('should require authorization without token', async () => { + const { document } = await seed(); + const res = await server.post('/api/documents.info', { + body: { id: document.id }, + }); + expect(res.status).toEqual(403); + }); + + it('should require authorization with incorrect token', async () => { + const { document } = await seed(); + const user = await buildUser(); + const res = await server.post('/api/documents.info', { + body: { token: user.getJwtToken(), id: document.id }, + }); + expect(res.status).toEqual(403); + }); + + it('should require a valid shareId', async () => { + const res = await server.post('/api/documents.info', { + body: { shareId: 123 }, + }); + expect(res.status).toEqual(400); + }); }); describe('#documents.list', async () => { diff --git a/server/api/index.js b/server/api/index.js index 45ae7c19a..26e9e1dc3 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -12,6 +12,7 @@ import documents from './documents'; import views from './views'; import hooks from './hooks'; import apiKeys from './apiKeys'; +import shares from './shares'; import team from './team'; import integrations from './integrations'; @@ -74,6 +75,7 @@ router.use('/', documents.routes()); router.use('/', views.routes()); router.use('/', hooks.routes()); router.use('/', apiKeys.routes()); +router.use('/', shares.routes()); router.use('/', team.routes()); router.use('/', integrations.routes()); diff --git a/server/api/middlewares/authentication.js b/server/api/middlewares/authentication.js index 26e06a281..3cd33ab67 100644 --- a/server/api/middlewares/authentication.js +++ b/server/api/middlewares/authentication.js @@ -4,7 +4,7 @@ import { type Context } from 'koa'; import { User, ApiKey } from '../../models'; import { AuthenticationError, UserSuspendedError } from '../../errors'; -export default function auth() { +export default function auth(options?: { required?: boolean } = {}) { return async function authMiddleware( ctx: Context, next: () => Promise @@ -33,58 +33,61 @@ export default function auth() { token = ctx.request.query.token; } - if (!token) throw new AuthenticationError('Authentication required'); + if (!token && options.required !== false) { + throw new AuthenticationError('Authentication required'); + } let user; + if (token) { + if (String(token).match(/^[\w]{38}$/)) { + // API key + let apiKey; + try { + apiKey = await ApiKey.findOne({ + where: { + secret: token, + }, + }); + } catch (e) { + throw new AuthenticationError('Invalid API key'); + } - if (String(token).match(/^[\w]{38}$/)) { - // API key - let apiKey; - try { - apiKey = await ApiKey.findOne({ - where: { - secret: token, - }, - }); - } catch (e) { - throw new AuthenticationError('Invalid API key'); + if (!apiKey) throw new AuthenticationError('Invalid API key'); + + user = await User.findById(apiKey.userId); + if (!user) throw new AuthenticationError('Invalid API key'); + } else { + // JWT + // Get user without verifying payload signature + let payload; + try { + payload = JWT.decode(token); + } catch (e) { + throw new AuthenticationError('Unable to decode JWT token'); + } + + if (!payload) throw new AuthenticationError('Invalid token'); + + user = await User.findById(payload.id); + + try { + JWT.verify(token, user.jwtSecret); + } catch (e) { + throw new AuthenticationError('Invalid token'); + } } - if (!apiKey) throw new AuthenticationError('Invalid API key'); - - user = await User.findById(apiKey.userId); - if (!user) throw new AuthenticationError('Invalid API key'); - } else { - // JWT - // Get user without verifying payload signature - let payload; - try { - payload = JWT.decode(token); - } catch (e) { - throw new AuthenticationError('Unable to decode JWT token'); + if (user.isSuspended) { + const suspendingAdmin = await User.findById(user.suspendedById); + throw new UserSuspendedError({ adminEmail: suspendingAdmin.email }); } - if (!payload) throw new AuthenticationError('Invalid token'); - - user = await User.findById(payload.id); - - try { - JWT.verify(token, user.jwtSecret); - } catch (e) { - throw new AuthenticationError('Invalid token'); - } + ctx.state.token = token; + ctx.state.user = user; + // $FlowFixMe + ctx.cache[user.id] = user; } - if (user.isSuspended) { - const suspendingAdmin = await User.findById(user.suspendedById); - throw new UserSuspendedError({ adminEmail: suspendingAdmin.email }); - } - - ctx.state.token = token; - ctx.state.user = user; - // $FlowFixMe - ctx.cache[user.id] = user; - return next(); }; } diff --git a/server/api/shares.js b/server/api/shares.js new file mode 100644 index 000000000..ce2fb9e11 --- /dev/null +++ b/server/api/shares.js @@ -0,0 +1,86 @@ +// @flow +import Router from 'koa-router'; +import auth from './middlewares/authentication'; +import pagination from './middlewares/pagination'; +import { presentShare } from '../presenters'; +import { Document, User, Share } from '../models'; +import policy from '../policies'; + +const { authorize } = policy; +const router = new Router(); + +router.post('shares.list', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const where = { teamId: user.teamId, userId: user.id }; + + if (user.isAdmin) delete where.userId; + + const shares = await Share.findAll({ + where, + order: [[sort, direction]], + include: [ + { + model: Document, + required: true, + as: 'document', + }, + { + model: User, + required: true, + as: 'user', + }, + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const data = await Promise.all(shares.map(share => presentShare(ctx, share))); + + ctx.body = { + data, + }; +}); + +router.post('shares.create', auth(), async ctx => { + const { documentId } = ctx.body; + ctx.assertPresent(documentId, 'documentId is required'); + + const user = ctx.state.user; + const document = await Document.findById(documentId); + authorize(user, 'share', document); + + const [share] = await Share.findOrCreate({ + where: { + documentId, + userId: user.id, + teamId: user.teamId, + }, + }); + + share.user = user; + share.document = document; + + ctx.body = { + data: presentShare(ctx, share), + }; +}); + +router.post('shares.delete', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const share = await Share.findById(id); + authorize(user, 'delete', share); + + await share.destroy(); + + ctx.body = { + success: true, + }; +}); + +export default router; diff --git a/server/api/shares.test.js b/server/api/shares.test.js new file mode 100644 index 000000000..730007779 --- /dev/null +++ b/server/api/shares.test.js @@ -0,0 +1,108 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from 'fetch-test-server'; +import app from '..'; +import { flushdb, seed } from '../test/support'; +import { buildUser, buildShare } from '../test/factories'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(server.close); + +describe('#shares.list', async () => { + it('should only return shares created by user', async () => { + const { user, document } = await seed(); + await buildShare({ + documentId: document.id, + teamId: user.teamId, + }); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post('/api/shares.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(share.id); + expect(body.data[0].documentTitle).toBe(document.title); + }); + + it('admins should only return shares created by all users', async () => { + const { admin, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: admin.teamId, + }); + const res = await server.post('/api/shares.list', { + body: { token: admin.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(share.id); + expect(body.data[0].documentTitle).toBe(document.title); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/shares.list'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#shares.create', async () => { + it('should allow creating a share record for document', async () => { + const { user, document } = await seed(); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), documentId: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.documentTitle).toBe(document.title); + }); + + it('should return existing share link for document and user', async () => { + const { user, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), documentId: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toBe(share.id); + }); + + it('should require authentication', async () => { + const { document } = await seed(); + const res = await server.post('/api/shares.create', { + body: { documentId: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const { document } = await seed(); + const user = await buildUser(); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), documentId: document.id }, + }); + expect(res.status).toEqual(403); + }); +}); diff --git a/server/emails/components/Button.js b/server/emails/components/Button.js index fbdb1af37..6b81faa55 100644 --- a/server/emails/components/Button.js +++ b/server/emails/components/Button.js @@ -15,5 +15,9 @@ export default (props: Props) => { cursor: 'pointer', }; - return {props.children}; + return ( + + {props.children} + + ); }; diff --git a/server/migrations/20180513041057-add-share-links.js b/server/migrations/20180513041057-add-share-links.js new file mode 100644 index 000000000..5d67446f9 --- /dev/null +++ b/server/migrations/20180513041057-add-share-links.js @@ -0,0 +1,44 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('shares', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + }, + }, + teamId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'teams', + }, + }, + documentId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'documents', + }, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('shares'); + }, +}; diff --git a/server/models/Share.js b/server/models/Share.js new file mode 100644 index 000000000..3965b8642 --- /dev/null +++ b/server/models/Share.js @@ -0,0 +1,27 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const Share = sequelize.define('share', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, +}); + +Share.associate = models => { + Share.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + }); + Share.belongsTo(models.Team, { + as: 'team', + foreignKey: 'teamId', + }); + Share.belongsTo(models.Document, { + as: 'document', + foreignKey: 'documentId', + }); +}; + +export default Share; diff --git a/server/models/index.js b/server/models/index.js index 8c50e5951..16d1606a5 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,28 +1,30 @@ // @flow +import ApiKey from './ApiKey'; import Authentication from './Authentication'; -import Integration from './Integration'; -import Event from './Event'; -import User from './User'; -import Team from './Team'; import Collection from './Collection'; import Document from './Document'; +import Event from './Event'; +import Integration from './Integration'; import Revision from './Revision'; -import ApiKey from './ApiKey'; -import View from './View'; +import Share from './Share'; import Star from './Star'; +import Team from './Team'; +import User from './User'; +import View from './View'; const models = { + ApiKey, Authentication, - Integration, - Event, - User, - Team, Collection, Document, + Event, + Integration, Revision, - ApiKey, - View, + Share, Star, + Team, + User, + View, }; // based on https://github.com/sequelize/express-example/blob/master/models/index.js @@ -33,15 +35,16 @@ Object.keys(models).forEach(modelName => { }); export { + ApiKey, Authentication, - Integration, - Event, - User, - Team, Collection, Document, + Event, + Integration, Revision, - ApiKey, - View, + Share, Star, + Team, + User, + View, }; diff --git a/server/policies/document.js b/server/policies/document.js index 60f6afbbf..cbe3be79a 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -8,7 +8,7 @@ allow(User, 'create', Document); allow( User, - ['read', 'update', 'delete'], + ['read', 'update', 'delete', 'share'], Document, (user, document) => user.teamId === document.teamId ); diff --git a/server/policies/index.js b/server/policies/index.js index b9ac4f5a7..2e1492403 100644 --- a/server/policies/index.js +++ b/server/policies/index.js @@ -4,6 +4,7 @@ import './apiKey'; import './collection'; import './document'; import './integration'; +import './share'; import './user'; export default policy; diff --git a/server/policies/share.js b/server/policies/share.js new file mode 100644 index 000000000..3e2d1121b --- /dev/null +++ b/server/policies/share.js @@ -0,0 +1,15 @@ +// @flow +import policy from './policy'; +import { Share, User } from '../models'; +import { AdminRequiredError } from '../errors'; + +const { allow } = policy; + +allow(User, ['read'], Share, (user, share) => user.teamId === share.teamId); +allow(User, ['update'], Share, (user, share) => false); +allow(User, ['delete'], Share, (user, share) => { + if (!share || user.teamId !== share.teamId) return false; + if (user.id === share.userId) return true; + if (user.isAdmin) return true; + throw new AdminRequiredError(); +}); diff --git a/server/presenters/collection.js b/server/presenters/collection.js index 965aad8ca..5b9af99da 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -42,8 +42,7 @@ async function present(ctx: Object, collection: Collection) { if (collection.documents) { data.recentDocuments = await Promise.all( collection.documents.map( - async document => - await presentDocument(ctx, document, { includeCollaborators: true }) + async document => await presentDocument(ctx, document) ) ); } diff --git a/server/presenters/document.js b/server/presenters/document.js index 0e7158119..37534e633 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -8,12 +8,12 @@ import presentCollection from './collection'; const Op = Sequelize.Op; type Options = { - includeCollaborators?: boolean, + isPublic?: boolean, }; async function present(ctx: Object, document: Document, options: ?Options) { options = { - includeCollaborators: true, + isPublic: false, ...options, }; ctx.cache.set(document.id, document); @@ -27,39 +27,43 @@ async function present(ctx: Object, document: Document, options: ?Options) { id: document.id, url: document.getUrl(), urlId: document.urlId, - private: document.private, title: document.title, text: document.text, emoji: document.emoji, createdAt: document.createdAt, - createdBy: presentUser(ctx, document.createdBy), + createdBy: undefined, updatedAt: document.updatedAt, - updatedBy: presentUser(ctx, document.updatedBy), + updatedBy: undefined, publishedAt: document.publishedAt, firstViewedAt: undefined, lastViewedAt: undefined, team: document.teamId, collaborators: [], starred: !!(document.starred && document.starred.length), - pinned: !!document.pinnedById, revision: document.revisionCount, - collectionId: document.atlasId, + pinned: undefined, + collectionId: undefined, collaboratorCount: undefined, collection: undefined, views: undefined, }; - if (document.private && document.collection) { - data.collection = await presentCollection(ctx, document.collection); - } + if (!options.isPublic) { + data.pinned = !!document.pinnedById; + data.collectionId = document.atlasId; + data.createdBy = presentUser(ctx, document.createdBy); + data.updatedBy = presentUser(ctx, document.updatedBy); - if (document.views && document.views.length === 1) { - data.views = document.views[0].count; - data.firstViewedAt = document.views[0].createdAt; - data.lastViewedAt = document.views[0].updatedAt; - } + if (document.collection) { + data.collection = await presentCollection(ctx, document.collection); + } + + if (document.views && document.views.length === 1) { + data.views = document.views[0].count; + data.firstViewedAt = document.views[0].createdAt; + data.lastViewedAt = document.views[0].updatedAt; + } - if (options.includeCollaborators) { // This could be further optimized by using ctx.cache data.collaborators = await User.findAll({ where: { diff --git a/server/presenters/index.js b/server/presenters/index.js index 3ec608df6..3796c23fa 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -5,6 +5,7 @@ import presentDocument from './document'; import presentRevision from './revision'; import presentCollection from './collection'; import presentApiKey from './apiKey'; +import presentShare from './share'; import presentTeam from './team'; import presentIntegration from './integration'; import presentSlackAttachment from './slackAttachment'; @@ -16,6 +17,7 @@ export { presentRevision, presentCollection, presentApiKey, + presentShare, presentTeam, presentIntegration, presentSlackAttachment, diff --git a/server/presenters/share.js b/server/presenters/share.js new file mode 100644 index 000000000..1de3825ca --- /dev/null +++ b/server/presenters/share.js @@ -0,0 +1,17 @@ +// @flow +import { Share } from '../models'; +import { presentUser } from '.'; + +function present(ctx: Object, share: Share) { + return { + id: share.id, + documentTitle: share.document.title, + documentUrl: share.document.getUrl(), + url: `${process.env.URL}/share/${share.id}`, + createdBy: presentUser(ctx, share.user), + createdAt: share.createdAt, + updatedAt: share.updatedAt, + }; +} + +export default present; diff --git a/server/test/factories.js b/server/test/factories.js index 92e6fb5b1..b76209087 100644 --- a/server/test/factories.js +++ b/server/test/factories.js @@ -1,9 +1,22 @@ // @flow -import { Team, User } from '../models'; +import { Share, Team, User } from '../models'; import uuid from 'uuid'; let count = 0; +export async function buildShare(overrides: Object = {}) { + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + if (!overrides.userId) { + const user = await buildUser({ teamId: overrides.teamId }); + overrides.userId = user.id; + } + + return Share.create(overrides); +} + export function buildTeam(overrides: Object = {}) { count++;