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