Collection Permissions (#829)
see https://github.com/outline/outline/issues/668
This commit is contained in:
parent
8978915423
commit
8c02b0028c
|
@ -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)`
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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({
|
|||
<label>
|
||||
{label && <LabelText>{label}</LabelText>}
|
||||
<Outline>
|
||||
<InputComponent {...rest} />
|
||||
<InputComponent
|
||||
type={type === 'textarea' ? undefined : type}
|
||||
{...rest}
|
||||
/>
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
|
|
|
@ -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 (
|
||||
<Wrapper>
|
||||
<Wrapper compact={compact}>
|
||||
{image && <Image>{image}</Image>}
|
||||
<Content>
|
||||
<Content align={compact ? 'center' : undefined} column={!compact}>
|
||||
<Heading>{title}</Heading>
|
||||
<Subtitle>{subtitle}</Subtitle>
|
||||
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||
</Content>
|
||||
{actions && <Actions>{actions}</Actions>}
|
||||
</Wrapper>
|
||||
|
@ -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;
|
||||
`;
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<Fade>
|
||||
{times(count || 2, index => (
|
||||
<Item key={index} column auto>
|
||||
<Mask />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
const Item = styled(Flex)`
|
||||
padding: 15px 0 16px;
|
||||
`;
|
||||
|
||||
export default Placeholder;
|
|
@ -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 (
|
||||
<Fade>
|
||||
{_.times(count || 2, index => (
|
||||
{times(count || 2, index => (
|
||||
<Item key={index} column auto>
|
||||
<Mask header />
|
||||
<Mask />
|
||||
|
@ -24,7 +24,7 @@ const ListPlaceHolder = ({ count }: Props) => {
|
|||
};
|
||||
|
||||
const Item = styled(Flex)`
|
||||
padding: 18px 0;
|
||||
padding: 10px 0;
|
||||
`;
|
||||
|
||||
export default ListPlaceHolder;
|
||||
|
|
|
@ -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 (
|
||||
<Fade>
|
||||
<Flex column auto {...props}>
|
||||
<Mask header />
|
||||
<Mask height={34} />
|
||||
<br />
|
||||
<Mask />
|
||||
<Mask />
|
||||
<Mask />
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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<Props> {
|
|||
<SidebarLink
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={<CollectionIcon expanded={expanded} color={collection.color} />}
|
||||
icon={
|
||||
collection.private ? (
|
||||
<PrivateCollectionIcon
|
||||
expanded={expanded}
|
||||
color={collection.color}
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon expanded={expanded} color={collection.color} />
|
||||
)
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expand={expanded}
|
||||
hideExpandToggle
|
||||
|
|
|
@ -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<Props> {
|
|||
const { history, location, collections, ui, documents } = this.props;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>Collections</Header>
|
||||
{collections.orderedData.map(collection => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
history={history}
|
||||
location={location}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
ui={ui}
|
||||
/>
|
||||
))}
|
||||
|
||||
{collections.isLoaded && (
|
||||
<SidebarLink
|
||||
onClick={this.props.onCreateCollection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
New collection…
|
||||
</SidebarLink>
|
||||
)}
|
||||
</Flex>
|
||||
collections.isLoaded && (
|
||||
<Fade>
|
||||
<Flex column>
|
||||
<Header>Collections</Header>
|
||||
{collections.orderedData.map(collection => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
history={history}
|
||||
location={location}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
ui={ui}
|
||||
/>
|
||||
))}
|
||||
<SidebarLink
|
||||
onClick={this.props.onCreateCollection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
New collection…
|
||||
</SidebarLink>
|
||||
</Flex>
|
||||
</Fade>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = (
|
||||
<Wrapper width={width} height={height}>
|
||||
<HiddenInput type="checkbox" width={width} height={height} {...props} />
|
||||
<Slider width={width} height={height} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Label htmlFor={props.id}>
|
||||
{component}
|
||||
<LabelText> {label}</LabelText>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
|
@ -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<Props> {
|
||||
file: ?HTMLInputElement;
|
||||
@observable permissionsModalOpen: boolean = false;
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
|
@ -71,17 +75,36 @@ class CollectionMenu extends React.Component<Props> {
|
|||
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 (
|
||||
<span>
|
||||
<React.Fragment>
|
||||
<HiddenInput
|
||||
type="file"
|
||||
ref={ref => (this.file = ref)}
|
||||
onChange={this.onFilePicked}
|
||||
accept="text/markdown, text/plain"
|
||||
/>
|
||||
<Modal
|
||||
title="Collection permissions"
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionPermissions
|
||||
collection={collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu
|
||||
label={label || <MoreIcon />}
|
||||
onOpen={onOpen}
|
||||
|
@ -97,6 +120,9 @@ class CollectionMenu extends React.Component<Props> {
|
|||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.onPermissions}>
|
||||
Permissions…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.onExport}>
|
||||
Export…
|
||||
</DropdownMenuItem>
|
||||
|
@ -104,7 +130,7 @@ class CollectionMenu extends React.Component<Props> {
|
|||
)}
|
||||
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,14 +76,7 @@ class DocumentMenu extends React.Component<Props> {
|
|||
};
|
||||
|
||||
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<Props> {
|
|||
Share link…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showToggleEmbeds && (
|
||||
<React.Fragment>
|
||||
{document.embedsDisabled ? (
|
||||
<DropdownMenuItem onClick={document.enableEmbeds}>
|
||||
Enable embeds
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={document.disableEmbeds}>
|
||||
Disable embeds
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||
Document history
|
||||
|
|
|
@ -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<Props> {
|
|||
key={collection.id}
|
||||
onClick={() => this.handleNewDocument(collection)}
|
||||
>
|
||||
<CollectionIcon color={collection.color} /> {collection.name}
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} />
|
||||
)}{' '}
|
||||
{collection.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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<Props> {
|
||||
@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<Props> {
|
|||
}
|
||||
};
|
||||
|
||||
onPermissions = (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
this.permissionsModalOpen = true;
|
||||
};
|
||||
|
||||
handlePermissionsModalClose = () => {
|
||||
this.permissionsModalOpen = false;
|
||||
};
|
||||
|
||||
renderActions() {
|
||||
return (
|
||||
<Actions align="center" justify="flex-end">
|
||||
|
@ -101,29 +119,6 @@ class CollectionScene extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
renderEmptyCollection() {
|
||||
if (!this.collection) return null;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={this.collection.name} />
|
||||
<Heading>
|
||||
<CollectionIcon color={this.collection.color} size={40} expanded />{' '}
|
||||
{this.collection.name}
|
||||
</Heading>
|
||||
<HelpText>
|
||||
Publish your first document to start building this collection.
|
||||
</HelpText>
|
||||
<Wrapper>
|
||||
<Link to={newDocumentUrl(this.collection)}>
|
||||
<Button>Create new document</Button>
|
||||
</Link>
|
||||
</Wrapper>
|
||||
{this.renderActions()}
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
renderNotFound() {
|
||||
return <Search notFound />;
|
||||
}
|
||||
|
@ -132,9 +127,6 @@ class CollectionScene extends React.Component<Props> {
|
|||
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<Props> {
|
|||
? this.props.documents.recentlyUpdatedInCollection(this.collection.id)
|
||||
: [];
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
const collection = this.collection;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
{this.collection ? (
|
||||
{collection ? (
|
||||
<React.Fragment>
|
||||
<PageTitle title={this.collection.name} />
|
||||
<PageTitle title={collection.name} />
|
||||
<Heading>
|
||||
<CollectionIcon
|
||||
color={this.collection.color}
|
||||
size={40}
|
||||
expanded
|
||||
/>{' '}
|
||||
{this.collection.name}
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon
|
||||
color={collection.color}
|
||||
size={40}
|
||||
expanded
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} size={40} expanded />
|
||||
)}{' '}
|
||||
{collection.name}
|
||||
</Heading>
|
||||
{this.collection.description && (
|
||||
<RichMarkdownEditor
|
||||
key={this.collection.description}
|
||||
defaultValue={this.collection.description}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
{collection.isEmpty ? (
|
||||
<React.Fragment>
|
||||
<Subheading>
|
||||
<TinyPinIcon size={18} /> Pinned
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} />
|
||||
<HelpText>
|
||||
Collections are for grouping your knowledge base. Get started
|
||||
by creating a new document.
|
||||
</HelpText>
|
||||
<Wrapper>
|
||||
<Link to={newDocumentUrl(collection)}>
|
||||
<Button>Create new document</Button>
|
||||
</Link>
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
Invite people
|
||||
</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
<Modal
|
||||
title="Collection permissions"
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionPermissions
|
||||
collection={this.collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{collection.description && (
|
||||
<RichMarkdownEditor
|
||||
key={collection.description}
|
||||
defaultValue={collection.description}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
<React.Fragment>
|
||||
<Subheading>
|
||||
<TinyPinIcon size={18} /> Pinned
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<Subheading>Recently edited</Subheading>
|
||||
<DocumentList documents={recentDocuments} limit={10} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<Subheading>Recently edited</Subheading>
|
||||
<DocumentList documents={recentDocuments} limit={10} />
|
||||
{this.renderActions()}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<ListPlaceholder count={5} />
|
||||
<React.Fragment>
|
||||
<Heading>
|
||||
<Mask height={35} />
|
||||
</Heading>
|
||||
<ListPlaceholder count={5} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
</CenteredContent>
|
||||
);
|
||||
|
|
|
@ -66,8 +66,8 @@ class CollectionEdit extends React.Component<Props> {
|
|||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
You can edit a collection’s details at any time, however doing so
|
||||
often might confuse your team mates.
|
||||
You can edit a collection’s name and other details at any time,
|
||||
however doing so often might confuse your team mates.
|
||||
</HelpText>
|
||||
<Input
|
||||
type="text"
|
||||
|
|
|
@ -4,6 +4,7 @@ import { withRouter } from 'react-router-dom';
|
|||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import Button from 'components/Button';
|
||||
import Switch from 'components/Switch';
|
||||
import Input from 'components/Input';
|
||||
import InputRich from 'components/InputRich';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
|
@ -25,6 +26,7 @@ class CollectionNew extends React.Component<Props> {
|
|||
@observable name: string = '';
|
||||
@observable description: string = '';
|
||||
@observable color: string = '';
|
||||
@observable private: boolean = false;
|
||||
@observable isSaving: boolean;
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
|
@ -35,6 +37,7 @@ class CollectionNew extends React.Component<Props> {
|
|||
name: this.name,
|
||||
description: this.description,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
},
|
||||
this.props.collections
|
||||
);
|
||||
|
@ -58,6 +61,10 @@ class CollectionNew extends React.Component<Props> {
|
|||
this.description = getValue();
|
||||
};
|
||||
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.private = ev.target.checked;
|
||||
};
|
||||
|
||||
handleColor = (color: string) => {
|
||||
this.color = color;
|
||||
};
|
||||
|
@ -87,6 +94,16 @@ class CollectionNew extends React.Component<Props> {
|
|||
maxHeight={200}
|
||||
/>
|
||||
<ColorPicker onSelect={this.handleColor} />
|
||||
<Switch
|
||||
id="private"
|
||||
label="Private collection"
|
||||
onChange={this.handlePrivateChange}
|
||||
checked={this.private}
|
||||
/>
|
||||
<HelpText>
|
||||
A private collection will only be visible to invited team members.
|
||||
</HelpText>
|
||||
|
||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||
{this.isSaving ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import { reject } from 'lodash';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Fade from 'components/Fade';
|
||||
import Input from 'components/Input';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Subheading from 'components/Subheading';
|
||||
import List from 'components/List';
|
||||
import Placeholder from 'components/List/Placeholder';
|
||||
import Switch from 'components/Switch';
|
||||
import UserListItem from './components/UserListItem';
|
||||
import MemberListItem from './components/MemberListItem';
|
||||
import Collection from 'models/Collection';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
users: UsersStore,
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
};
|
||||
|
||||
@observer
|
||||
class CollectionPermissions extends React.Component<Props> {
|
||||
@observable isSaving: boolean;
|
||||
@observable filter: string;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.users.fetchPage();
|
||||
this.props.collection.fetchUsers();
|
||||
}
|
||||
|
||||
handlePrivateChange = async (ev: SyntheticInputEvent<*>) => {
|
||||
const { collection } = this.props;
|
||||
|
||||
try {
|
||||
collection.private = ev.target.checked;
|
||||
await collection.save();
|
||||
|
||||
if (collection.private) {
|
||||
await collection.fetchUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
collection.private = !ev.target.checked;
|
||||
this.props.ui.showToast('Collection privacy could not be changed');
|
||||
}
|
||||
};
|
||||
|
||||
handleAddUser = user => {
|
||||
try {
|
||||
this.props.collection.addUser(user);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast('Could not add user');
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveUser = user => {
|
||||
try {
|
||||
this.props.collection.removeUser(user);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast('Could not remove user');
|
||||
}
|
||||
};
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<*>) => {
|
||||
this.filter = ev.target.value.toLowerCase();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, users, auth } = this.props;
|
||||
const { user } = auth;
|
||||
if (!user) return null;
|
||||
|
||||
const otherUsers = reject(users.active, user =>
|
||||
collection.userIds.includes(user.id)
|
||||
);
|
||||
const hasOtherUsers = !!otherUsers.length;
|
||||
const isFirstLoadingUsers =
|
||||
collection.isLoadingUsers && !collection.users.length;
|
||||
const filteredUsers = reject(
|
||||
otherUsers,
|
||||
user => this.filter && !user.name.toLowerCase().includes(this.filter)
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<HelpText>
|
||||
Choose which people on the team have access to read and edit documents
|
||||
in the <strong>{collection.name}</strong> collection. By default
|
||||
collections are visible to all team members.
|
||||
</HelpText>
|
||||
|
||||
<Switch
|
||||
id="private"
|
||||
label="Private collection"
|
||||
onChange={this.handlePrivateChange}
|
||||
checked={collection.private}
|
||||
/>
|
||||
|
||||
{collection.private && (
|
||||
<Fade>
|
||||
<Flex column>
|
||||
<Subheading>Invited ({collection.users.length})</Subheading>
|
||||
<List>
|
||||
{isFirstLoadingUsers ? (
|
||||
<Placeholder />
|
||||
) : (
|
||||
collection.users.map(member => (
|
||||
<MemberListItem
|
||||
key={member.id}
|
||||
user={member}
|
||||
showRemove={user.id !== member.id}
|
||||
onRemove={() => this.handleRemoveUser(member)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
{hasOtherUsers && (
|
||||
<React.Fragment>
|
||||
<Subheading>Team Members</Subheading>
|
||||
<Input
|
||||
onChange={this.handleFilter}
|
||||
placeholder="Filter…"
|
||||
value={this.filter}
|
||||
type="search"
|
||||
/>
|
||||
<List>
|
||||
{filteredUsers.map(member => (
|
||||
<UserListItem
|
||||
key={member.id}
|
||||
user={member}
|
||||
onAdd={() => this.handleAddUser(member)}
|
||||
showAdd
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Flex>
|
||||
</Fade>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'ui', 'users')(CollectionPermissions);
|
|
@ -0,0 +1,42 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import Avatar from 'components/Avatar';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import ListItem from 'components/List/Item';
|
||||
import User from 'models/User';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
showRemove: boolean,
|
||||
onRemove: () => *,
|
||||
};
|
||||
|
||||
const MemberListItem = ({ user, onRemove, showRemove }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
<Permission as="span">Can edit </Permission>
|
||||
{showRemove && (
|
||||
<DropdownMenu label={<MoreIcon />}>
|
||||
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Permission = styled(HelpText)`
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
export default MemberListItem;
|
|
@ -0,0 +1,32 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Button from 'components/Button';
|
||||
import ListItem from 'components/List/Item';
|
||||
import User from 'models/User';
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
showAdd: boolean,
|
||||
onAdd: () => *,
|
||||
};
|
||||
|
||||
const UserListItem = ({ user, onAdd, showAdd }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
actions={
|
||||
showAdd ? (
|
||||
<Button type="button" onClick={onAdd}>
|
||||
Invite
|
||||
</Button>
|
||||
) : (
|
||||
undefined
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserListItem;
|
|
@ -0,0 +1,3 @@
|
|||
// @flow
|
||||
import CollectionPermissions from './CollectionPermissions';
|
||||
export default CollectionPermissions;
|
|
@ -4,7 +4,7 @@ import { observer, inject } from 'mobx-react';
|
|||
import breakpoint from 'styled-components-breakpoint';
|
||||
import styled from 'styled-components';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CollectionIcon, GoToIcon } from 'outline-icons';
|
||||
import { CollectionIcon, PrivateCollectionIcon, GoToIcon } from 'outline-icons';
|
||||
|
||||
import Document from 'models/Document';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
|
@ -26,7 +26,11 @@ const Breadcrumb = observer(({ document, collections }: Props) => {
|
|||
return (
|
||||
<Wrapper justify="flex-start" align="center">
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon color={collection.color} expanded />{' '}
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} expanded />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} expanded />
|
||||
)}{' '}
|
||||
<span>{collection.name}</span>
|
||||
</CollectionName>
|
||||
{path.map(n => (
|
||||
|
|
|
@ -35,6 +35,7 @@ class SearchField extends React.Component<Props> {
|
|||
onChange={this.handleChange}
|
||||
spellCheck="false"
|
||||
placeholder="search…"
|
||||
type="search"
|
||||
autoFocus
|
||||
/>
|
||||
</Flex>
|
||||
|
|
|
@ -16,7 +16,6 @@ type Props = {
|
|||
const UserListItem = ({ user, showMenu }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
key={user.id}
|
||||
title={user.name}
|
||||
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||
subtitle={
|
||||
|
|
|
@ -36,6 +36,16 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||
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
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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']);
|
||||
},
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
|
@ -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 },
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -152,11 +152,12 @@ export default function Pricing() {
|
|||
|
||||
<Method method="collections.update" label="Update a collection">
|
||||
<Description>
|
||||
This method allows you to modify already created document.
|
||||
This method allows you to modify an already created collection.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument id="id" description="Collection ID" required />
|
||||
<Argument id="name" description="Name for the collection" />
|
||||
<Argument id="private" description="Boolean" />
|
||||
<Argument
|
||||
id="color"
|
||||
description="Collection color in hex form (e.g. #E1E1E1)"
|
||||
|
@ -164,6 +165,45 @@ export default function Pricing() {
|
|||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="collections.add_user" label="Add a collection member">
|
||||
<Description>
|
||||
This method allows you to add a user to a private collection.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument id="id" description="Collection ID" required />
|
||||
<Argument
|
||||
id="userId"
|
||||
description="User ID to add to the collection"
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method
|
||||
method="collections.remove_user"
|
||||
label="Remove a collection member"
|
||||
>
|
||||
<Description>
|
||||
This method allows you to remove a user from a private collection.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument id="id" description="Collection ID" required />
|
||||
<Argument
|
||||
id="userId"
|
||||
description="User ID to remove from the collection"
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="collections.users" label="List collection members">
|
||||
<Description>
|
||||
This method allows you to list users with access to a private
|
||||
collection.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument id="id" description="Collection ID" required />
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="collections.delete" label="Delete a collection">
|
||||
<Description>
|
||||
Delete a collection and all of its documents. This action can’t be
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -77,6 +77,7 @@ export default class Notifications {
|
|||
],
|
||||
});
|
||||
if (!collection) return;
|
||||
if (collection.private) return;
|
||||
|
||||
const notificationSettings = await NotificationSetting.findAll({
|
||||
where: {
|
||||
|
|
|
@ -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"
|
||||
|
|
Reference in New Issue