feat: Memberships (#1032)
* WIP * feat: Add collection.memberships endpoint * feat: Add ability to filter collection.memberships with query * WIP * Merge stashed work * feat: Add ability to filter memberships by permission * continued refactoring * paginated list component * Collection member management * fix: Incorrect policy data sent down after collection.update * Reduce duplication, add empty state * cleanup * fix: Modal close should be a real button * fix: Allow opening edit from modal * fix: remove unused methods * test: fix * Passing test suite * Refactor * fix: Flow UI errors * test: Add collections.update tests * lint * test: moar tests * fix: Missing scopes, more missing tests * fix: Handle collection privacy change over socket * fix: More membership scopes * fix: view endpoint permissions * fix: respond to privacy change on socket event * policy driven menus * fix: share endpoint policies * chore: Use policies to drive documents UI * alignment * fix: Header height * fix: Correct behavior when collection becomes private * fix: Header height for read-only collection * send id's over socket instead of serialized objects * fix: Remote policy change * fix: reduce collection fetching * More websocket efficiencies * fix: Document collection pinning * fix: Restored ability to edit drafts fix: Removed ability to star drafts * fix: Require write permissions to pin doc to collection * fix: Header title overlaying document actions at small screen sizes * fix: Jank on load caused by previous commit * fix: Double collection fetch post-publish * fix: Hide publish button if draft is in no longer accessible collection * fix: Always allow deleting drafts fix: Improved handling of deleted documents * feat: Show collections in drafts view feat: Show more obvious 'draft' badge on documents * fix: incorrect policies after publish to private collection * fix: Duplicating a draft publishes it
This commit is contained in:
parent
4164fc178c
commit
b42e9737b6
|
@ -14,6 +14,7 @@
|
||||||
.*/node_modules/slate-edit-list/.*
|
.*/node_modules/slate-edit-list/.*
|
||||||
.*/node_modules/slate-prism/.*
|
.*/node_modules/slate-prism/.*
|
||||||
.*/node_modules/config-chain/.*
|
.*/node_modules/config-chain/.*
|
||||||
|
.*/server/scripts/.*
|
||||||
*.test.js
|
*.test.js
|
||||||
|
|
||||||
[libs]
|
[libs]
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
dist
|
dist
|
||||||
node_modules/*
|
node_modules/*
|
||||||
|
server/scripts
|
||||||
.env
|
.env
|
||||||
.log
|
.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
|
@ -7,6 +7,7 @@ export const Action = styled(Flex)`
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 0 0 12px;
|
padding: 0 0 0 12px;
|
||||||
|
height: 32px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,9 @@ const Badge = styled.span`
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
padding: 2px 6px 3px;
|
padding: 2px 6px 3px;
|
||||||
background-color: ${({ admin, theme }) =>
|
background-color: ${({ admin, theme }) =>
|
||||||
admin ? theme.primary : theme.smokeDark};
|
admin ? theme.primary : theme.textTertiary};
|
||||||
color: ${({ admin, theme }) => (admin ? theme.white : theme.text)};
|
color: ${({ admin, theme }) => (admin ? theme.white : theme.background)};
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { Link } from 'react-router-dom';
|
||||||
import { StarredIcon } from 'outline-icons';
|
import { StarredIcon } from 'outline-icons';
|
||||||
import styled, { withTheme } from 'styled-components';
|
import styled, { withTheme } from 'styled-components';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
|
import Badge from 'components/Badge';
|
||||||
|
import Tooltip from 'components/Tooltip';
|
||||||
import Highlight from 'components/Highlight';
|
import Highlight from 'components/Highlight';
|
||||||
import PublishingInfo from 'components/PublishingInfo';
|
import PublishingInfo from 'components/PublishingInfo';
|
||||||
import DocumentMenu from 'menus/DocumentMenu';
|
import DocumentMenu from 'menus/DocumentMenu';
|
||||||
|
@ -17,6 +19,7 @@ type Props = {
|
||||||
showCollection?: boolean,
|
showCollection?: boolean,
|
||||||
showPublished?: boolean,
|
showPublished?: boolean,
|
||||||
showPin?: boolean,
|
showPin?: boolean,
|
||||||
|
showDraft?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||||
|
@ -130,6 +133,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||||
showCollection,
|
showCollection,
|
||||||
showPublished,
|
showPublished,
|
||||||
showPin,
|
showPin,
|
||||||
|
showDraft = true,
|
||||||
highlight,
|
highlight,
|
||||||
context,
|
context,
|
||||||
...rest
|
...rest
|
||||||
|
@ -159,6 +163,16 @@ class DocumentPreview extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
</Actions>
|
</Actions>
|
||||||
)}
|
)}
|
||||||
|
{document.isDraft &&
|
||||||
|
showDraft && (
|
||||||
|
<Tooltip
|
||||||
|
tooltip="Only visible to you"
|
||||||
|
delay={500}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Badge>Draft</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<StyledDocumentMenu document={document} showPin={showPin} />
|
<StyledDocumentMenu document={document} showPin={showPin} />
|
||||||
</Heading>
|
</Heading>
|
||||||
{!queryIsInTitle && (
|
{!queryIsInTitle && (
|
||||||
|
|
|
@ -4,9 +4,11 @@ import invariant from 'invariant';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { PortalWithState } from 'react-portal';
|
import { PortalWithState } from 'react-portal';
|
||||||
|
import { MoreIcon } from 'outline-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||||
|
import NudeButton from 'components/NudeButton';
|
||||||
|
|
||||||
let previousClosePortal;
|
let previousClosePortal;
|
||||||
|
|
||||||
|
@ -15,7 +17,7 @@ type Children =
|
||||||
| ((options: { closePortal: () => void }) => React.Node);
|
| ((options: { closePortal: () => void }) => React.Node);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: React.Node,
|
label?: React.Node,
|
||||||
onOpen?: () => void,
|
onOpen?: () => void,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
children?: Children,
|
children?: Children,
|
||||||
|
@ -76,7 +78,11 @@ class DropdownMenu extends React.Component<Props> {
|
||||||
{({ closePortal, openPortal, portal }) => (
|
{({ closePortal, openPortal, portal }) => (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Label onClick={this.handleOpen(openPortal, closePortal)}>
|
<Label onClick={this.handleOpen(openPortal, closePortal)}>
|
||||||
{label}
|
{label || (
|
||||||
|
<NudeButton>
|
||||||
|
<MoreIcon />
|
||||||
|
</NudeButton>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
{portal(
|
{portal(
|
||||||
<Position
|
<Position
|
||||||
|
|
|
@ -8,9 +8,13 @@ type Props = {
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DropdownMenuItem = ({ onClick, children, ...rest }: Props) => {
|
const DropdownMenuItem = ({ onClick, children, disabled, ...rest }: Props) => {
|
||||||
return (
|
return (
|
||||||
<MenuItem onClick={onClick} {...rest}>
|
<MenuItem
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
|
@ -33,9 +37,13 @@ const MenuItem = styled.a`
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: ${props => (props.disabled ? '.5' : 1)};
|
||||||
|
}
|
||||||
|
|
||||||
${props =>
|
${props =>
|
||||||
props.disabled
|
props.disabled
|
||||||
? ''
|
? 'pointer-events: none;'
|
||||||
: `
|
: `
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import * as React from 'react';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import styled, { withTheme } from 'styled-components';
|
import styled, { withTheme } from 'styled-components';
|
||||||
import Input, { LabelText, Outline } from 'components/Input';
|
import { LabelText, Outline } from 'components/Input';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: string,
|
label: string,
|
||||||
|
@ -41,26 +41,22 @@ class InputRich extends React.Component<Props> {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<LabelText>{label}</LabelText>
|
<LabelText>{label}</LabelText>
|
||||||
{Editor ? (
|
|
||||||
<StyledOutline
|
<StyledOutline
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
focused={this.focused}
|
focused={this.focused}
|
||||||
>
|
>
|
||||||
|
{Editor ? (
|
||||||
<Editor
|
<Editor
|
||||||
onBlur={this.handleBlur}
|
onBlur={this.handleBlur}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
</StyledOutline>
|
) : (
|
||||||
) : (
|
'Loading…'
|
||||||
<Input
|
)}
|
||||||
maxHeight={maxHeight}
|
</StyledOutline>
|
||||||
minHeight={minHeight}
|
|
||||||
placeholder="Loading…"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { observable } from 'mobx';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import VisuallyHidden from 'components/VisuallyHidden';
|
||||||
|
import { Outline, LabelText } from './Input';
|
||||||
|
|
||||||
|
const Select = styled.select`
|
||||||
|
border: 0;
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
background: none;
|
||||||
|
color: ${props => props.theme.text};
|
||||||
|
|
||||||
|
&:disabled,
|
||||||
|
&::placeholder {
|
||||||
|
color: ${props => props.theme.placeholder};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Option = { label: string, value: string };
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
value?: string,
|
||||||
|
label?: string,
|
||||||
|
className?: string,
|
||||||
|
labelHidden?: boolean,
|
||||||
|
options: Option[],
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class InputSelect extends React.Component<Props> {
|
||||||
|
@observable focused: boolean = false;
|
||||||
|
|
||||||
|
handleBlur = () => {
|
||||||
|
this.focused = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFocus = () => {
|
||||||
|
this.focused = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { label, className, labelHidden, options, ...rest } = this.props;
|
||||||
|
|
||||||
|
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{label &&
|
||||||
|
(labelHidden ? (
|
||||||
|
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||||
|
) : (
|
||||||
|
wrappedLabel
|
||||||
|
))}
|
||||||
|
<Outline focused={this.focused} className={className}>
|
||||||
|
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
|
||||||
|
{options.map(option => (
|
||||||
|
<option value={option.value} key={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Outline>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InputSelect;
|
|
@ -30,6 +30,10 @@ const Wrapper = styled.li`
|
||||||
padding: ${props => (props.compact ? '8px' : '12px')} 0;
|
padding: ${props => (props.compact ? '8px' : '12px')} 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-bottom: 1px solid ${props => props.theme.divider};
|
border-bottom: 1px solid ${props => props.theme.divider};
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Image = styled(Flex)`
|
const Image = styled(Flex)`
|
||||||
|
|
|
@ -6,6 +6,7 @@ import breakpoint from 'styled-components-breakpoint';
|
||||||
import ReactModal from 'react-modal';
|
import ReactModal from 'react-modal';
|
||||||
import { transparentize } from 'polished';
|
import { transparentize } from 'polished';
|
||||||
import { CloseIcon } from 'outline-icons';
|
import { CloseIcon } from 'outline-icons';
|
||||||
|
import NudeButton from 'components/NudeButton';
|
||||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
|
|
||||||
|
@ -90,16 +91,18 @@ const StyledModal = styled(ReactModal)`
|
||||||
const Esc = styled.span`
|
const Esc = styled.span`
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: -10px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
height: 1em;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Close = styled.a`
|
const Close = styled(NudeButton)`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
color: ${props => props.theme.text};
|
color: ${props => props.theme.text};
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observable, action } from 'mobx';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import Waypoint from 'react-waypoint';
|
||||||
|
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||||
|
|
||||||
|
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
||||||
|
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fetch?: (options: ?Object) => Promise<void>,
|
||||||
|
options?: Object,
|
||||||
|
empty?: React.Node,
|
||||||
|
items: any[],
|
||||||
|
renderItem: any => React.Node,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class PaginatedList extends React.Component<Props> {
|
||||||
|
isInitiallyLoaded: boolean = false;
|
||||||
|
@observable isLoaded: boolean = false;
|
||||||
|
@observable isFetchingMore: boolean = false;
|
||||||
|
@observable isFetching: boolean = false;
|
||||||
|
@observable offset: number = 0;
|
||||||
|
@observable allowLoadMore: boolean = true;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.isInitiallyLoaded = !!this.props.items.length;
|
||||||
|
this.fetchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResults = async () => {
|
||||||
|
if (!this.props.fetch) return;
|
||||||
|
|
||||||
|
this.isFetching = true;
|
||||||
|
|
||||||
|
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||||
|
const results = await this.props.fetch({
|
||||||
|
limit,
|
||||||
|
offset: this.offset,
|
||||||
|
...this.props.options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results && (results.length === 0 || results.length < limit)) {
|
||||||
|
this.allowLoadMore = false;
|
||||||
|
} else {
|
||||||
|
this.offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoaded = true;
|
||||||
|
this.isFetching = false;
|
||||||
|
this.isFetchingMore = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
loadMoreResults = async () => {
|
||||||
|
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||||
|
if (!this.allowLoadMore || this.isFetching) return;
|
||||||
|
|
||||||
|
this.isFetchingMore = true;
|
||||||
|
await this.fetchResults();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { items, empty } = this.props;
|
||||||
|
|
||||||
|
const showLoading =
|
||||||
|
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
||||||
|
const showEmpty = !items.length || showLoading;
|
||||||
|
const showList = (this.isLoaded || this.isInitiallyLoaded) && !showLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{showEmpty && empty}
|
||||||
|
{showList && (
|
||||||
|
<React.Fragment>
|
||||||
|
<ArrowKeyNavigation
|
||||||
|
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||||
|
defaultActiveChildIndex={0}
|
||||||
|
>
|
||||||
|
{items.map(this.props.renderItem)}
|
||||||
|
</ArrowKeyNavigation>
|
||||||
|
{this.allowLoadMore && (
|
||||||
|
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
{showLoading && <ListPlaceholder count={5} />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PaginatedList;
|
|
@ -91,7 +91,7 @@ function PublishingInfo({
|
||||||
<span>
|
<span>
|
||||||
in
|
in
|
||||||
<strong>
|
<strong>
|
||||||
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
<Breadcrumb document={document} onlyText />
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -62,7 +62,7 @@ class MainSidebar extends React.Component<Props> {
|
||||||
if (!user || !team) return null;
|
if (!user || !team) return null;
|
||||||
|
|
||||||
const draftDocumentsCount = documents.drafts.length;
|
const draftDocumentsCount = documents.drafts.length;
|
||||||
const can = policies.abilties(team.id);
|
const can = policies.abilities(team.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
|
|
|
@ -43,7 +43,7 @@ class SettingsSidebar extends React.Component<Props> {
|
||||||
const { team } = auth;
|
const { team } = auth;
|
||||||
if (!team) return null;
|
if (!team) return null;
|
||||||
|
|
||||||
const can = policies.abilties(team.id);
|
const can = policies.abilities(team.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
|
|
|
@ -13,11 +13,13 @@ import CollectionLink from './CollectionLink';
|
||||||
import Fade from 'components/Fade';
|
import Fade from 'components/Fade';
|
||||||
|
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
history: RouterHistory,
|
history: RouterHistory,
|
||||||
|
policies: PoliciesStore,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
onCreateCollection: () => void,
|
onCreateCollection: () => void,
|
||||||
|
@ -41,6 +43,9 @@ class Collections extends React.Component<Props> {
|
||||||
const { activeCollectionId } = this.props.ui;
|
const { activeCollectionId } = this.props.ui;
|
||||||
if (!activeCollectionId) return;
|
if (!activeCollectionId) return;
|
||||||
|
|
||||||
|
const can = this.props.policies.abilities(activeCollectionId);
|
||||||
|
if (!can.update) return;
|
||||||
|
|
||||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,6 +80,6 @@ class Collections extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject('collections', 'ui', 'documents')(
|
export default inject('collections', 'ui', 'documents', 'policies')(
|
||||||
withRouter(Collections)
|
withRouter(Collections)
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { inject } from 'mobx-react';
|
import { inject } from 'mobx-react';
|
||||||
|
import { find } from 'lodash';
|
||||||
import io from 'socket.io-client';
|
import io from 'socket.io-client';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import MembershipsStore from 'stores/MembershipsStore';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
|
|
||||||
|
@ -13,6 +16,8 @@ type Props = {
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
|
memberships: MembershipsStore,
|
||||||
|
policies: PoliciesStore,
|
||||||
auth: AuthStore,
|
auth: AuthStore,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
};
|
};
|
||||||
|
@ -27,34 +32,81 @@ class SocketProvider extends React.Component<Props> {
|
||||||
path: '/realtime',
|
path: '/realtime',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, ui, documents, collections } = this.props;
|
const {
|
||||||
|
auth,
|
||||||
|
ui,
|
||||||
|
documents,
|
||||||
|
collections,
|
||||||
|
memberships,
|
||||||
|
policies,
|
||||||
|
} = this.props;
|
||||||
if (!auth.token) return;
|
if (!auth.token) return;
|
||||||
|
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
this.socket.emit('authentication', {
|
this.socket.emit('authentication', {
|
||||||
token: auth.token,
|
token: auth.token,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('unauthorized', err => {
|
this.socket.on('unauthorized', err => {
|
||||||
ui.showToast(err.message);
|
ui.showToast(err.message);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
this.socket.on('entities', event => {
|
|
||||||
if (event.documents) {
|
this.socket.on('entities', async event => {
|
||||||
event.documents.forEach(doc => {
|
if (event.documentIds) {
|
||||||
if (doc.deletedAt) {
|
for (const documentDescriptor of event.documentIds) {
|
||||||
documents.remove(doc.id);
|
const documentId = documentDescriptor.id;
|
||||||
} else {
|
let document = documents.get(documentId) || {};
|
||||||
documents.add(doc);
|
|
||||||
|
if (event.event === 'documents.delete') {
|
||||||
|
documents.remove(documentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we already have the latest version (it was us that performed the change)
|
||||||
|
// the we don't need to update anything either.
|
||||||
|
const { title, updatedAt } = document;
|
||||||
|
if (updatedAt === documentDescriptor.updatedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, grab the latest version of the document
|
||||||
|
try {
|
||||||
|
document = await documents.fetch(documentId, {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||||
|
documents.remove(documentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the title changed then we need to update the collection also
|
||||||
|
if (title !== document.title) {
|
||||||
|
if (!event.collectionIds) {
|
||||||
|
event.collectionIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = find(event.collectionIds, {
|
||||||
|
id: document.collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
event.collectionIds.push({
|
||||||
|
id: document.collectionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move this to the document scene once data loading
|
// TODO: Move this to the document scene once data loading
|
||||||
// has been refactored to be friendlier there.
|
// has been refactored to be friendlier there.
|
||||||
if (
|
if (
|
||||||
auth.user &&
|
auth.user &&
|
||||||
doc.id === ui.activeDocumentId &&
|
documentId === ui.activeDocumentId &&
|
||||||
doc.updatedBy.id !== auth.user.id
|
document.updatedBy.id !== auth.user.id
|
||||||
) {
|
) {
|
||||||
ui.showToast(`Document updated by ${doc.updatedBy.name}`, {
|
ui.showToast(`Document updated by ${document.updatedBy.name}`, {
|
||||||
timeout: 30 * 1000,
|
timeout: 30 * 1000,
|
||||||
action: {
|
action: {
|
||||||
text: 'Refresh',
|
text: 'Refresh',
|
||||||
|
@ -62,26 +114,69 @@ class SocketProvider extends React.Component<Props> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
if (event.collections) {
|
|
||||||
event.collections.forEach(collection => {
|
if (event.collectionIds) {
|
||||||
if (collection.deletedAt) {
|
for (const collectionDescriptor of event.collectionIds) {
|
||||||
collections.remove(collection.id);
|
const collectionId = collectionDescriptor.id;
|
||||||
documents.removeCollectionDocuments(collection.id);
|
const collection = collections.get(collectionId) || {};
|
||||||
} else {
|
|
||||||
collections.add(collection);
|
if (event.event === 'collections.delete') {
|
||||||
|
documents.remove(collectionId);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// if we already have the latest version (it was us that performed the change)
|
||||||
|
// the we don't need to update anything either.
|
||||||
|
const { updatedAt } = collection;
|
||||||
|
if (updatedAt === collectionDescriptor.updatedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await collections.fetch(collectionId, { force: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||||
|
collections.remove(collectionId);
|
||||||
|
documents.removeCollectionDocuments(collectionId);
|
||||||
|
memberships.removeCollectionMemberships(collectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('documents.star', event => {
|
this.socket.on('documents.star', event => {
|
||||||
documents.starredIds.set(event.documentId, true);
|
documents.starredIds.set(event.documentId, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('documents.unstar', event => {
|
this.socket.on('documents.unstar', event => {
|
||||||
documents.starredIds.set(event.documentId, false);
|
documents.starredIds.set(event.documentId, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.socket.on('collections.add_user', event => {
|
||||||
|
if (auth.user && event.userId === auth.user.id) {
|
||||||
|
collections.fetch(event.collectionId, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document policies might need updating as the permission changes
|
||||||
|
documents.inCollection(event.collectionId).forEach(document => {
|
||||||
|
policies.remove(document.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('collections.remove_user', event => {
|
||||||
|
if (auth.user && event.userId === auth.user.id) {
|
||||||
|
collections.remove(event.collectionId);
|
||||||
|
memberships.removeCollectionMemberships(event.collectionId);
|
||||||
|
documents.removeCollectionDocuments(event.collectionId);
|
||||||
|
} else {
|
||||||
|
memberships.remove(`${event.userId}-${event.collectionId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// received a message from the API server that we should request
|
// received a message from the API server that we should request
|
||||||
// to join a specific room. Forward that to the ws server.
|
// to join a specific room. Forward that to the ws server.
|
||||||
this.socket.on('join', event => {
|
this.socket.on('join', event => {
|
||||||
|
@ -96,6 +191,10 @@ class SocketProvider extends React.Component<Props> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.socket.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<SocketContext.Provider value={this.socket}>
|
<SocketContext.Provider value={this.socket}>
|
||||||
|
@ -105,4 +204,11 @@ class SocketProvider extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject('auth', 'ui', 'documents', 'collections')(SocketProvider);
|
export default inject(
|
||||||
|
'auth',
|
||||||
|
'ui',
|
||||||
|
'documents',
|
||||||
|
'collections',
|
||||||
|
'memberships',
|
||||||
|
'policies'
|
||||||
|
)(SocketProvider);
|
||||||
|
|
|
@ -4,9 +4,8 @@ import { observable } from 'mobx';
|
||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { MoreIcon } from 'outline-icons';
|
|
||||||
import Modal from 'components/Modal';
|
import Modal from 'components/Modal';
|
||||||
import CollectionPermissions from 'scenes/CollectionPermissions';
|
import CollectionMembers from 'scenes/CollectionMembers';
|
||||||
|
|
||||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||||
|
@ -14,12 +13,13 @@ import importFile from 'utils/importFile';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
import NudeButton from 'components/NudeButton';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
position?: 'left' | 'right' | 'center',
|
position?: 'left' | 'right' | 'center',
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
|
policies: PoliciesStore,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
history: RouterHistory,
|
history: RouterHistory,
|
||||||
|
@ -30,7 +30,7 @@ type Props = {
|
||||||
@observer
|
@observer
|
||||||
class CollectionMenu extends React.Component<Props> {
|
class CollectionMenu extends React.Component<Props> {
|
||||||
file: ?HTMLInputElement;
|
file: ?HTMLInputElement;
|
||||||
@observable permissionsModalOpen: boolean = false;
|
@observable membersModalOpen: boolean = false;
|
||||||
@observable redirectTo: ?string;
|
@observable redirectTo: ?string;
|
||||||
|
|
||||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||||
|
@ -81,15 +81,16 @@ class CollectionMenu extends React.Component<Props> {
|
||||||
|
|
||||||
onPermissions = (ev: SyntheticEvent<>) => {
|
onPermissions = (ev: SyntheticEvent<>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.permissionsModalOpen = true;
|
this.membersModalOpen = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePermissionsModalClose = () => {
|
handleMembersModalClose = () => {
|
||||||
this.permissionsModalOpen = false;
|
this.membersModalOpen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { collection, position, onOpen, onClose } = this.props;
|
const { policies, collection, position, onOpen, onClose } = this.props;
|
||||||
|
const can = policies.abilities(collection.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
@ -100,44 +101,48 @@ class CollectionMenu extends React.Component<Props> {
|
||||||
accept="text/markdown, text/plain"
|
accept="text/markdown, text/plain"
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="Collection permissions"
|
title="Collection members"
|
||||||
onRequestClose={this.handlePermissionsModalClose}
|
onRequestClose={this.handleMembersModalClose}
|
||||||
isOpen={this.permissionsModalOpen}
|
isOpen={this.membersModalOpen}
|
||||||
>
|
>
|
||||||
<CollectionPermissions
|
<CollectionMembers
|
||||||
collection={collection}
|
collection={collection}
|
||||||
onSubmit={this.handlePermissionsModalClose}
|
onSubmit={this.handleMembersModalClose}
|
||||||
|
onEdit={this.onEdit}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<DropdownMenu
|
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
|
||||||
label={
|
|
||||||
<NudeButton>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
}
|
|
||||||
onOpen={onOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
position={position}
|
|
||||||
>
|
|
||||||
{collection && (
|
{collection && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<DropdownMenuItem onClick={this.onNewDocument}>
|
{can.update && (
|
||||||
New document
|
<DropdownMenuItem onClick={this.onNewDocument}>
|
||||||
</DropdownMenuItem>
|
New document
|
||||||
<DropdownMenuItem onClick={this.onImportDocument}>
|
</DropdownMenuItem>
|
||||||
Import document
|
)}
|
||||||
</DropdownMenuItem>
|
{can.update && (
|
||||||
<hr />
|
<DropdownMenuItem onClick={this.onImportDocument}>
|
||||||
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
Import document
|
||||||
<DropdownMenuItem onClick={this.onPermissions}>
|
</DropdownMenuItem>
|
||||||
Permissions…
|
)}
|
||||||
</DropdownMenuItem>
|
{can.update && <hr />}
|
||||||
<DropdownMenuItem onClick={this.onExport}>
|
{can.update && (
|
||||||
Export…
|
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
|
{can.update && (
|
||||||
|
<DropdownMenuItem onClick={this.onPermissions}>
|
||||||
|
Members…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{can.export && (
|
||||||
|
<DropdownMenuItem onClick={this.onExport}>
|
||||||
|
Export…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
{can.delete && (
|
||||||
|
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
@ -151,4 +156,6 @@ const HiddenInput = styled.input`
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject('ui', 'documents')(withRouter(CollectionMenu));
|
export default inject('ui', 'documents', 'policies')(
|
||||||
|
withRouter(CollectionMenu)
|
||||||
|
);
|
||||||
|
|
|
@ -3,12 +3,12 @@ import * as React from 'react';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import { MoreIcon } from 'outline-icons';
|
|
||||||
|
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
import CollectionStore from 'stores/CollectionsStore';
|
import CollectionStore from 'stores/CollectionsStore';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
import {
|
import {
|
||||||
documentMoveUrl,
|
documentMoveUrl,
|
||||||
documentEditUrl,
|
documentEditUrl,
|
||||||
|
@ -16,7 +16,6 @@ import {
|
||||||
newDocumentUrl,
|
newDocumentUrl,
|
||||||
} from 'utils/routeHelpers';
|
} from 'utils/routeHelpers';
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
import NudeButton from 'components/NudeButton';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
|
@ -24,6 +23,7 @@ type Props = {
|
||||||
position?: 'left' | 'right' | 'center',
|
position?: 'left' | 'right' | 'center',
|
||||||
document: Document,
|
document: Document,
|
||||||
collections: CollectionStore,
|
collections: CollectionStore,
|
||||||
|
policies: PoliciesStore,
|
||||||
className: string,
|
className: string,
|
||||||
showPrint?: boolean,
|
showPrint?: boolean,
|
||||||
showToggleEmbeds?: boolean,
|
showToggleEmbeds?: boolean,
|
||||||
|
@ -111,6 +111,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
policies,
|
||||||
document,
|
document,
|
||||||
position,
|
position,
|
||||||
className,
|
className,
|
||||||
|
@ -120,110 +121,94 @@ class DocumentMenu extends React.Component<Props> {
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const canShareDocuments = auth.team && auth.team.sharing;
|
|
||||||
|
|
||||||
if (document.isArchived) {
|
const can = policies.abilities(document.id);
|
||||||
return (
|
const canShareDocuments = can.share && auth.team && auth.team.sharing;
|
||||||
<DropdownMenu
|
|
||||||
label={
|
|
||||||
<NudeButton>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onClick={this.handleRestore}>
|
|
||||||
Restore
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleDelete}>
|
|
||||||
Delete…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
label={
|
|
||||||
<NudeButton>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
}
|
|
||||||
className={className}
|
className={className}
|
||||||
position={position}
|
position={position}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{!document.isDraft ? (
|
{can.unarchive && (
|
||||||
<React.Fragment>
|
<DropdownMenuItem onClick={this.handleRestore}>
|
||||||
{showPin &&
|
Restore
|
||||||
(document.pinned ? (
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{showPin &&
|
||||||
|
(document.pinned
|
||||||
|
? can.unpin && (
|
||||||
<DropdownMenuItem onClick={this.handleUnpin}>
|
<DropdownMenuItem onClick={this.handleUnpin}>
|
||||||
Unpin
|
Unpin
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
)
|
||||||
|
: can.pin && (
|
||||||
<DropdownMenuItem onClick={this.handlePin}>
|
<DropdownMenuItem onClick={this.handlePin}>
|
||||||
Pin to collection
|
Pin to collection
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
{document.isStarred ? (
|
{document.isStarred
|
||||||
|
? can.unstar && (
|
||||||
<DropdownMenuItem onClick={this.handleUnstar}>
|
<DropdownMenuItem onClick={this.handleUnstar}>
|
||||||
Unstar
|
Unstar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
)
|
||||||
|
: can.star && (
|
||||||
<DropdownMenuItem onClick={this.handleStar}>
|
<DropdownMenuItem onClick={this.handleStar}>
|
||||||
Star
|
Star
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{canShareDocuments && (
|
{canShareDocuments && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={this.handleShareLink}
|
onClick={this.handleShareLink}
|
||||||
title="Create a public share link"
|
title="Create a public share link"
|
||||||
>
|
>
|
||||||
Share link…
|
Share link…
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
|
||||||
<hr />
|
|
||||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
|
||||||
Document history
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={this.handleNewChild}
|
|
||||||
title="Create a new child document for the current document"
|
|
||||||
>
|
|
||||||
New child document
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleDuplicate}>
|
|
||||||
Duplicate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleArchive}>
|
|
||||||
Archive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleDelete}>
|
|
||||||
Delete…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
|
||||||
</React.Fragment>
|
|
||||||
) : (
|
|
||||||
<React.Fragment>
|
|
||||||
{canShareDocuments && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={this.handleShareLink}
|
|
||||||
title="Create a public share link"
|
|
||||||
>
|
|
||||||
Share link…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={this.handleDelete}>
|
|
||||||
Delete…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
)}
|
||||||
<hr />
|
<hr />
|
||||||
<DropdownMenuItem onClick={this.handleExport}>
|
{can.read && (
|
||||||
Download
|
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||||
</DropdownMenuItem>
|
Document history
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{can.update && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={this.handleNewChild}
|
||||||
|
title="Create a new child document for the current document"
|
||||||
|
>
|
||||||
|
New child document
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{can.update && (
|
||||||
|
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{can.update && (
|
||||||
|
<DropdownMenuItem onClick={this.handleDuplicate}>
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{can.archive && (
|
||||||
|
<DropdownMenuItem onClick={this.handleArchive}>
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{can.delete && (
|
||||||
|
<DropdownMenuItem onClick={this.handleDelete}>
|
||||||
|
Delete…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{can.move && (
|
||||||
|
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<hr />
|
||||||
|
{can.download && (
|
||||||
|
<DropdownMenuItem onClick={this.handleExport}>
|
||||||
|
Download
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{showPrint && (
|
{showPrint && (
|
||||||
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
|
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
@ -232,4 +217,4 @@ class DocumentMenu extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject('ui', 'auth', 'collections')(DocumentMenu);
|
export default inject('ui', 'auth', 'collections', 'policies')(DocumentMenu);
|
||||||
|
|
|
@ -7,12 +7,14 @@ import { PlusIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||||
|
|
||||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label?: React.Node,
|
label?: React.Node,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
|
policies: PoliciesStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
|
@ -38,7 +40,7 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||||
|
|
||||||
const { collections, label, ...rest } = this.props;
|
const { collections, policies, label, ...rest } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
|
@ -53,22 +55,27 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem disabled>Choose a collection…</DropdownMenuItem>
|
<DropdownMenuItem disabled>Choose a collection…</DropdownMenuItem>
|
||||||
{collections.orderedData.map(collection => (
|
{collections.orderedData.map(collection => {
|
||||||
<DropdownMenuItem
|
const can = policies.abilities(collection.id);
|
||||||
key={collection.id}
|
|
||||||
onClick={() => this.handleNewDocument(collection.id)}
|
return (
|
||||||
>
|
<DropdownMenuItem
|
||||||
{collection.private ? (
|
key={collection.id}
|
||||||
<PrivateCollectionIcon color={collection.color} />
|
onClick={() => this.handleNewDocument(collection.id)}
|
||||||
) : (
|
disabled={!can.update}
|
||||||
<CollectionIcon color={collection.color} />
|
>
|
||||||
)}{' '}
|
{collection.private ? (
|
||||||
{collection.name}
|
<PrivateCollectionIcon color={collection.color} />
|
||||||
</DropdownMenuItem>
|
) : (
|
||||||
))}
|
<CollectionIcon color={collection.color} />
|
||||||
|
)}{' '}
|
||||||
|
{collection.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject('collections')(NewDocumentMenu);
|
export default inject('collections', 'policies')(NewDocumentMenu);
|
||||||
|
|
|
@ -2,9 +2,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||||
import { inject } from 'mobx-react';
|
import { inject } from 'mobx-react';
|
||||||
import { MoreIcon } from 'outline-icons';
|
|
||||||
|
|
||||||
import NudeButton from 'components/NudeButton';
|
|
||||||
import CopyToClipboard from 'components/CopyToClipboard';
|
import CopyToClipboard from 'components/CopyToClipboard';
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
import { documentHistoryUrl } from 'utils/routeHelpers';
|
import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||||
|
@ -42,16 +40,7 @@ class RevisionMenu extends React.Component<Props> {
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu onOpen={onOpen} onClose={onClose} className={className}>
|
||||||
label={
|
|
||||||
<NudeButton>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
}
|
|
||||||
onOpen={onOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onClick={this.handleRestore}>
|
<DropdownMenuItem onClick={this.handleRestore}>
|
||||||
Restore version
|
Restore version
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
@ -3,9 +3,7 @@ import * as React from 'react';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { MoreIcon } from 'outline-icons';
|
|
||||||
|
|
||||||
import NudeButton from 'components/NudeButton';
|
|
||||||
import CopyToClipboard from 'components/CopyToClipboard';
|
import CopyToClipboard from 'components/CopyToClipboard';
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
import SharesStore from 'stores/SharesStore';
|
import SharesStore from 'stores/SharesStore';
|
||||||
|
@ -49,15 +47,7 @@ class ShareMenu extends React.Component<Props> {
|
||||||
const { share, onOpen, onClose } = this.props;
|
const { share, onOpen, onClose } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu onOpen={onOpen} onClose={onClose}>
|
||||||
label={
|
|
||||||
<NudeButton>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
}
|
|
||||||
onOpen={onOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<CopyToClipboard text={share.url} onCopy={this.handleCopy}>
|
<CopyToClipboard text={share.url} onCopy={this.handleCopy}>
|
||||||
<DropdownMenuItem>Copy link</DropdownMenuItem>
|
<DropdownMenuItem>Copy link</DropdownMenuItem>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import { MoreIcon } from 'outline-icons';
|
|
||||||
|
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
import NudeButton from 'components/NudeButton';
|
|
||||||
import UsersStore from 'stores/UsersStore';
|
import UsersStore from 'stores/UsersStore';
|
||||||
import User from 'models/User';
|
import User from 'models/User';
|
||||||
|
|
||||||
|
@ -62,13 +60,7 @@ class UserMenu extends React.Component<Props> {
|
||||||
const { user } = this.props;
|
const { user } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu>
|
||||||
label={
|
|
||||||
<NudeButton>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!user.isSuspended &&
|
{!user.isSuspended &&
|
||||||
(user.isAdmin ? (
|
(user.isAdmin ? (
|
||||||
<DropdownMenuItem onClick={this.handleDemote}>
|
<DropdownMenuItem onClick={this.handleDemote}>
|
||||||
|
|
|
@ -1,17 +1,14 @@
|
||||||
// @flow
|
// @flow
|
||||||
import invariant from 'invariant';
|
import { pick } from 'lodash';
|
||||||
import { map, without, pick, filter } from 'lodash';
|
|
||||||
import { action, computed, observable } from 'mobx';
|
import { action, computed, observable } from 'mobx';
|
||||||
import BaseModel from 'models/BaseModel';
|
import BaseModel from 'models/BaseModel';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import User from 'models/User';
|
|
||||||
import { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
import type { NavigationNode } from 'types';
|
import type { NavigationNode } from 'types';
|
||||||
|
|
||||||
export default class Collection extends BaseModel {
|
export default class Collection extends BaseModel {
|
||||||
@observable isSaving: boolean;
|
@observable isSaving: boolean;
|
||||||
@observable isLoadingUsers: boolean;
|
@observable isLoadingUsers: boolean;
|
||||||
@observable userIds: string[] = [];
|
|
||||||
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -48,45 +45,6 @@ export default class Collection extends BaseModel {
|
||||||
return results;
|
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
|
@action
|
||||||
updateDocument(document: Document) {
|
updateDocument(document: Document) {
|
||||||
const travelDocuments = (documentList, path) =>
|
const travelDocuments = (documentList, path) =>
|
||||||
|
|
|
@ -102,7 +102,9 @@ export default class Document extends BaseModel {
|
||||||
pin = async () => {
|
pin = async () => {
|
||||||
this.pinned = true;
|
this.pinned = true;
|
||||||
try {
|
try {
|
||||||
await this.store.pin(this);
|
const res = await this.store.pin(this);
|
||||||
|
invariant(res && res.data, 'Data should be available');
|
||||||
|
this.updateFromJson(res.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.pinned = false;
|
this.pinned = false;
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -113,7 +115,9 @@ export default class Document extends BaseModel {
|
||||||
unpin = async () => {
|
unpin = async () => {
|
||||||
this.pinned = false;
|
this.pinned = false;
|
||||||
try {
|
try {
|
||||||
await this.store.unpin(this);
|
const res = await this.store.unpin(this);
|
||||||
|
invariant(res && res.data, 'Data should be available');
|
||||||
|
this.updateFromJson(res.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.pinned = true;
|
this.pinned = true;
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -147,7 +151,6 @@ export default class Document extends BaseModel {
|
||||||
if (this.isSaving) return this;
|
if (this.isSaving) return this;
|
||||||
|
|
||||||
const isCreating = !this.id;
|
const isCreating = !this.id;
|
||||||
const wasDraft = !this.publishedAt;
|
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.updateTitle();
|
this.updateTitle();
|
||||||
|
|
||||||
|
@ -170,11 +173,6 @@ export default class Document extends BaseModel {
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (wasDraft && options.publish) {
|
|
||||||
this.store.rootStore.collections.fetch(this.collectionId, {
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
// @flow
|
||||||
|
import { computed } from 'mobx';
|
||||||
|
import BaseModel from './BaseModel';
|
||||||
|
|
||||||
|
class Membership extends BaseModel {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
collectionId: string;
|
||||||
|
permission: string;
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isEditor(): boolean {
|
||||||
|
return this.permission === 'read_write';
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isMaintainer(): boolean {
|
||||||
|
return this.permission === 'maintainer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Membership;
|
|
@ -17,10 +17,12 @@ import RichMarkdownEditor from 'rich-markdown-editor';
|
||||||
import { newDocumentUrl, collectionUrl } from 'utils/routeHelpers';
|
import { newDocumentUrl, collectionUrl } from 'utils/routeHelpers';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
|
|
||||||
import Search from 'scenes/Search';
|
import Search from 'scenes/Search';
|
||||||
|
import CollectionEdit from 'scenes/CollectionEdit';
|
||||||
import CollectionMenu from 'menus/CollectionMenu';
|
import CollectionMenu from 'menus/CollectionMenu';
|
||||||
import Actions, { Action, Separator } from 'components/Actions';
|
import Actions, { Action, Separator } from 'components/Actions';
|
||||||
import Heading from 'components/Heading';
|
import Heading from 'components/Heading';
|
||||||
|
@ -35,7 +37,7 @@ import Subheading from 'components/Subheading';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import Modal from 'components/Modal';
|
import Modal from 'components/Modal';
|
||||||
import CollectionPermissions from 'scenes/CollectionPermissions';
|
import CollectionMembers from 'scenes/CollectionMembers';
|
||||||
import Tabs from 'components/Tabs';
|
import Tabs from 'components/Tabs';
|
||||||
import Tab from 'components/Tab';
|
import Tab from 'components/Tab';
|
||||||
import PaginatedDocumentList from 'components/PaginatedDocumentList';
|
import PaginatedDocumentList from 'components/PaginatedDocumentList';
|
||||||
|
@ -44,6 +46,7 @@ type Props = {
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
|
policies: PoliciesStore,
|
||||||
match: Object,
|
match: Object,
|
||||||
theme: Object,
|
theme: Object,
|
||||||
};
|
};
|
||||||
|
@ -53,6 +56,7 @@ class CollectionScene extends React.Component<Props> {
|
||||||
@observable collection: ?Collection;
|
@observable collection: ?Collection;
|
||||||
@observable isFetching: boolean = true;
|
@observable isFetching: boolean = true;
|
||||||
@observable permissionsModalOpen: boolean = false;
|
@observable permissionsModalOpen: boolean = false;
|
||||||
|
@observable editModalOpen: boolean = false;
|
||||||
@observable redirectTo: ?string;
|
@observable redirectTo: ?string;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
@ -77,7 +81,7 @@ class CollectionScene extends React.Component<Props> {
|
||||||
this.collection = collection;
|
this.collection = collection;
|
||||||
|
|
||||||
await this.props.documents.fetchPinned({
|
await this.props.documents.fetchPinned({
|
||||||
collection: id,
|
collectionId: id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,22 +105,36 @@ class CollectionScene extends React.Component<Props> {
|
||||||
this.permissionsModalOpen = false;
|
this.permissionsModalOpen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleEditModalOpen = () => {
|
||||||
|
this.editModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleEditModalClose = () => {
|
||||||
|
this.editModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
renderActions() {
|
renderActions() {
|
||||||
|
const can = this.props.policies.abilities(this.props.match.params.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Actions align="center" justify="flex-end">
|
<Actions align="center" justify="flex-end">
|
||||||
<Action>
|
{can.update && (
|
||||||
<Tooltip
|
<React.Fragment>
|
||||||
tooltip="New document"
|
<Action>
|
||||||
shortcut="n"
|
<Tooltip
|
||||||
delay={500}
|
tooltip="New document"
|
||||||
placement="bottom"
|
shortcut="n"
|
||||||
>
|
delay={500}
|
||||||
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
|
placement="bottom"
|
||||||
New doc
|
>
|
||||||
</Button>
|
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
|
||||||
</Tooltip>
|
New doc
|
||||||
</Action>
|
</Button>
|
||||||
<Separator />
|
</Tooltip>
|
||||||
|
</Action>
|
||||||
|
<Separator />
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
<Action>
|
<Action>
|
||||||
<CollectionMenu collection={this.collection} />
|
<CollectionMenu collection={this.collection} />
|
||||||
</Action>
|
</Action>
|
||||||
|
@ -155,18 +173,29 @@ class CollectionScene extends React.Component<Props> {
|
||||||
</Link>
|
</Link>
|
||||||
{collection.private && (
|
{collection.private && (
|
||||||
<Button onClick={this.onPermissions} neutral>
|
<Button onClick={this.onPermissions} neutral>
|
||||||
Invite people
|
Manage members…
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
<Modal
|
<Modal
|
||||||
title="Collection permissions"
|
title="Collection members"
|
||||||
onRequestClose={this.handlePermissionsModalClose}
|
onRequestClose={this.handlePermissionsModalClose}
|
||||||
isOpen={this.permissionsModalOpen}
|
isOpen={this.permissionsModalOpen}
|
||||||
>
|
>
|
||||||
<CollectionPermissions
|
<CollectionMembers
|
||||||
collection={this.collection}
|
collection={this.collection}
|
||||||
onSubmit={this.handlePermissionsModalClose}
|
onSubmit={this.handlePermissionsModalClose}
|
||||||
|
onEdit={this.handleEditModalOpen}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title="Edit collection"
|
||||||
|
onRequestClose={this.handleEditModalClose}
|
||||||
|
isOpen={this.editModalOpen}
|
||||||
|
>
|
||||||
|
<CollectionEdit
|
||||||
|
collection={this.collection}
|
||||||
|
onSubmit={this.handleEditModalClose}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Centered>
|
</Centered>
|
||||||
|
@ -304,6 +333,6 @@ const Wrapper = styled(Flex)`
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject('collections', 'documents', 'ui')(
|
export default inject('collections', 'policies', 'documents', 'ui')(
|
||||||
withTheme(CollectionScene)
|
withTheme(CollectionScene)
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import Input from 'components/Input';
|
import Input from 'components/Input';
|
||||||
import InputRich from 'components/InputRich';
|
import InputRich from 'components/InputRich';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
|
import Switch from 'components/Switch';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
import ColorPicker from 'components/ColorPicker';
|
import ColorPicker from 'components/ColorPicker';
|
||||||
|
@ -13,7 +13,6 @@ import Collection from 'models/Collection';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
history: Object,
|
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
|
@ -25,11 +24,13 @@ class CollectionEdit extends React.Component<Props> {
|
||||||
@observable description: string = '';
|
@observable description: string = '';
|
||||||
@observable color: string = '#4E5C6E';
|
@observable color: string = '#4E5C6E';
|
||||||
@observable isSaving: boolean;
|
@observable isSaving: boolean;
|
||||||
|
@observable private: boolean = false;
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
this.name = this.props.collection.name;
|
this.name = this.props.collection.name;
|
||||||
this.description = this.props.collection.description;
|
this.description = this.props.collection.description;
|
||||||
this.color = this.props.collection.color;
|
this.color = this.props.collection.color;
|
||||||
|
this.private = this.props.collection.private;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||||
|
@ -41,8 +42,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
description: this.description,
|
description: this.description,
|
||||||
color: this.color,
|
color: this.color,
|
||||||
|
private: this.private,
|
||||||
});
|
});
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
|
this.props.ui.showToast('The collection was updated');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.props.ui.showToast(err.message);
|
this.props.ui.showToast(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -62,6 +65,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||||
this.color = color;
|
this.color = color;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
|
||||||
|
this.private = ev.target.checked;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Flex column>
|
<Flex column>
|
||||||
|
@ -91,6 +98,15 @@ class CollectionEdit extends React.Component<Props> {
|
||||||
minHeight={68}
|
minHeight={68}
|
||||||
maxHeight={200}
|
maxHeight={200}
|
||||||
/>
|
/>
|
||||||
|
<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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={this.isSaving || !this.props.collection.name}
|
disabled={this.isSaving || !this.props.collection.name}
|
||||||
|
@ -103,4 +119,4 @@ class CollectionEdit extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject('ui')(withRouter(CollectionEdit));
|
export default inject('ui')(CollectionEdit);
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { inject, observer } from 'mobx-react';
|
||||||
|
import { observable } from 'mobx';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import Flex from 'shared/components/Flex';
|
||||||
|
import HelpText from 'components/HelpText';
|
||||||
|
import Input from 'components/Input';
|
||||||
|
import Modal from 'components/Modal';
|
||||||
|
import Empty from 'components/Empty';
|
||||||
|
import PaginatedList from 'components/PaginatedList';
|
||||||
|
import Invite from 'scenes/Invite';
|
||||||
|
import Collection from 'models/Collection';
|
||||||
|
import UiStore from 'stores/UiStore';
|
||||||
|
import AuthStore from 'stores/AuthStore';
|
||||||
|
import UsersStore from 'stores/UsersStore';
|
||||||
|
import MembershipsStore from 'stores/MembershipsStore';
|
||||||
|
import MemberListItem from './components/MemberListItem';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ui: UiStore,
|
||||||
|
auth: AuthStore,
|
||||||
|
collection: Collection,
|
||||||
|
memberships: MembershipsStore,
|
||||||
|
users: UsersStore,
|
||||||
|
onSubmit: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class AddPeopleToCollection extends React.Component<Props> {
|
||||||
|
@observable inviteModalOpen: boolean = false;
|
||||||
|
@observable query: string = '';
|
||||||
|
|
||||||
|
handleInviteModalOpen = () => {
|
||||||
|
this.inviteModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleInviteModalClose = () => {
|
||||||
|
this.inviteModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||||
|
this.query = ev.target.value;
|
||||||
|
this.debouncedFetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
debouncedFetch = debounce(() => {
|
||||||
|
this.props.users.fetchPage({
|
||||||
|
query: this.query,
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
handleAddUser = user => {
|
||||||
|
try {
|
||||||
|
this.props.memberships.create({
|
||||||
|
collectionId: this.props.collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
this.props.ui.showToast(`${user.name} was added to the collection`);
|
||||||
|
} catch (err) {
|
||||||
|
this.props.ui.showToast('Could not add user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { users, collection, auth } = this.props;
|
||||||
|
const { user, team } = auth;
|
||||||
|
if (!user || !team) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex column>
|
||||||
|
<HelpText>
|
||||||
|
Need to add someone who’s not yet on the team yet?{' '}
|
||||||
|
<a role="button" onClick={this.handleInviteModalOpen}>
|
||||||
|
Invite people to {team.name}
|
||||||
|
</a>.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by name…"
|
||||||
|
value={this.query}
|
||||||
|
onChange={this.handleFilter}
|
||||||
|
label="Search people"
|
||||||
|
labelHidden
|
||||||
|
flex
|
||||||
|
/>
|
||||||
|
<PaginatedList
|
||||||
|
empty={
|
||||||
|
this.query ? (
|
||||||
|
<Empty>No people matching your search</Empty>
|
||||||
|
) : (
|
||||||
|
<Empty>No people left to add</Empty>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items={users.notInCollection(collection.id, this.query)}
|
||||||
|
fetch={this.query ? undefined : users.fetchPage}
|
||||||
|
renderItem={item => (
|
||||||
|
<MemberListItem
|
||||||
|
key={item.id}
|
||||||
|
user={item}
|
||||||
|
onAdd={() => this.handleAddUser(item)}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="Invite people"
|
||||||
|
onRequestClose={this.handleInviteModalClose}
|
||||||
|
isOpen={this.inviteModalOpen}
|
||||||
|
>
|
||||||
|
<Invite onSubmit={this.handleInviteModalClose} />
|
||||||
|
</Modal>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('auth', 'users', 'memberships', 'ui')(
|
||||||
|
AddPeopleToCollection
|
||||||
|
);
|
|
@ -0,0 +1,143 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observable } from 'mobx';
|
||||||
|
import { inject, observer } from 'mobx-react';
|
||||||
|
import { PlusIcon } from 'outline-icons';
|
||||||
|
import Flex from 'shared/components/Flex';
|
||||||
|
import HelpText from 'components/HelpText';
|
||||||
|
import Subheading from 'components/Subheading';
|
||||||
|
import Button from 'components/Button';
|
||||||
|
import PaginatedList from 'components/PaginatedList';
|
||||||
|
import Modal from 'components/Modal';
|
||||||
|
import Collection from 'models/Collection';
|
||||||
|
import UiStore from 'stores/UiStore';
|
||||||
|
import AuthStore from 'stores/AuthStore';
|
||||||
|
import MembershipsStore from 'stores/MembershipsStore';
|
||||||
|
import UsersStore from 'stores/UsersStore';
|
||||||
|
import MemberListItem from './components/MemberListItem';
|
||||||
|
import AddPeopleToCollection from './AddPeopleToCollection';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ui: UiStore,
|
||||||
|
auth: AuthStore,
|
||||||
|
collection: Collection,
|
||||||
|
users: UsersStore,
|
||||||
|
memberships: MembershipsStore,
|
||||||
|
onEdit: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class CollectionMembers extends React.Component<Props> {
|
||||||
|
@observable addModalOpen: boolean = false;
|
||||||
|
|
||||||
|
handleAddModalOpen = () => {
|
||||||
|
this.addModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAddModalClose = () => {
|
||||||
|
this.addModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRemoveUser = user => {
|
||||||
|
try {
|
||||||
|
this.props.memberships.delete({
|
||||||
|
collectionId: this.props.collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
this.props.ui.showToast(`${user.name} was removed from the collection`);
|
||||||
|
} catch (err) {
|
||||||
|
this.props.ui.showToast('Could not remove user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleUpdateUser = (user, permission) => {
|
||||||
|
try {
|
||||||
|
this.props.memberships.create({
|
||||||
|
collectionId: this.props.collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
this.props.ui.showToast(`${user.name} permissions were updated`);
|
||||||
|
} catch (err) {
|
||||||
|
this.props.ui.showToast('Could not update user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { collection, users, memberships, auth } = this.props;
|
||||||
|
const { user } = auth;
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const key = memberships.orderedData.map(m => m.permission).join('-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex column>
|
||||||
|
{collection.private ? (
|
||||||
|
<React.Fragment>
|
||||||
|
<HelpText>
|
||||||
|
Choose which team members have access to view and edit documents
|
||||||
|
in the private <strong>{collection.name}</strong> collection. You
|
||||||
|
can make this collection visible to the entire team by{' '}
|
||||||
|
<a role="button" onClick={this.props.onEdit}>
|
||||||
|
changing its visibility
|
||||||
|
</a>.
|
||||||
|
</HelpText>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleAddModalOpen}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
Add people
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
|
<HelpText>
|
||||||
|
The <strong>{collection.name}</strong> collection is accessible by
|
||||||
|
everyone on the team. If you want to limit who can view the
|
||||||
|
collection,{' '}
|
||||||
|
<a role="button" onClick={this.props.onEdit}>
|
||||||
|
make it private
|
||||||
|
</a>.
|
||||||
|
</HelpText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Subheading>Members</Subheading>
|
||||||
|
<PaginatedList
|
||||||
|
key={key}
|
||||||
|
items={
|
||||||
|
collection.private
|
||||||
|
? users.inCollection(collection.id)
|
||||||
|
: users.orderedData
|
||||||
|
}
|
||||||
|
fetch={collection.private ? memberships.fetchPage : users.fetchPage}
|
||||||
|
options={collection.private ? { id: collection.id } : undefined}
|
||||||
|
renderItem={item => (
|
||||||
|
<MemberListItem
|
||||||
|
key={item.id}
|
||||||
|
user={item}
|
||||||
|
membership={memberships.get(`${item.id}-${collection.id}`)}
|
||||||
|
canEdit={collection.private && item.id !== user.id}
|
||||||
|
onRemove={() => this.handleRemoveUser(item)}
|
||||||
|
onUpdate={permission => this.handleUpdateUser(item, permission)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={`Add people to ${collection.name}`}
|
||||||
|
onRequestClose={this.handleAddModalClose}
|
||||||
|
isOpen={this.addModalOpen}
|
||||||
|
>
|
||||||
|
<AddPeopleToCollection
|
||||||
|
collection={collection}
|
||||||
|
onSubmit={this.handleAddModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('auth', 'users', 'memberships', 'ui')(CollectionMembers);
|
|
@ -0,0 +1,82 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Avatar from 'components/Avatar';
|
||||||
|
import Flex from 'shared/components/Flex';
|
||||||
|
import Time from 'shared/components/Time';
|
||||||
|
import Badge from 'components/Badge';
|
||||||
|
import Button from 'components/Button';
|
||||||
|
import InputSelect from 'components/InputSelect';
|
||||||
|
import ListItem from 'components/List/Item';
|
||||||
|
import User from 'models/User';
|
||||||
|
import Membership from 'models/Membership';
|
||||||
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
|
|
||||||
|
const PERMISSIONS = [
|
||||||
|
{ label: 'Read only', value: 'read' },
|
||||||
|
{ label: 'Read & Edit', value: 'read_write' },
|
||||||
|
];
|
||||||
|
type Props = {
|
||||||
|
user: User,
|
||||||
|
membership?: ?Membership,
|
||||||
|
canEdit: boolean,
|
||||||
|
onAdd?: () => void,
|
||||||
|
onRemove?: () => void,
|
||||||
|
onUpdate?: (permission: string) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemberListItem = ({
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
onRemove,
|
||||||
|
onUpdate,
|
||||||
|
onAdd,
|
||||||
|
canEdit,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
title={user.name}
|
||||||
|
subtitle={
|
||||||
|
<React.Fragment>
|
||||||
|
Joined <Time dateTime={user.createdAt} /> ago
|
||||||
|
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||||
|
actions={
|
||||||
|
<Flex align="center">
|
||||||
|
{canEdit &&
|
||||||
|
onUpdate && (
|
||||||
|
<Select
|
||||||
|
label="Permissions"
|
||||||
|
options={PERMISSIONS}
|
||||||
|
value={membership ? membership.permission : undefined}
|
||||||
|
onChange={ev => onUpdate(ev.target.value)}
|
||||||
|
labelHidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit &&
|
||||||
|
onRemove && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{canEdit &&
|
||||||
|
onAdd && (
|
||||||
|
<Button onClick={onAdd} neutral>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Select = styled(InputSelect)`
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default MemberListItem;
|
|
@ -1,5 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { PlusIcon } from 'outline-icons';
|
||||||
import Avatar from 'components/Avatar';
|
import Avatar from 'components/Avatar';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import ListItem from 'components/List/Item';
|
import ListItem from 'components/List/Item';
|
||||||
|
@ -7,19 +8,19 @@ import User from 'models/User';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User,
|
user: User,
|
||||||
showAdd: boolean,
|
canEdit: boolean,
|
||||||
onAdd: () => void,
|
onAdd: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserListItem = ({ user, onAdd, showAdd }: Props) => {
|
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
title={user.name}
|
title={user.name}
|
||||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||||
actions={
|
actions={
|
||||||
showAdd ? (
|
canEdit ? (
|
||||||
<Button type="button" onClick={onAdd} neutral>
|
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
|
||||||
Invite
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
undefined
|
undefined
|
|
@ -0,0 +1,3 @@
|
||||||
|
// @flow
|
||||||
|
import CollectionMembers from './CollectionMembers';
|
||||||
|
export default CollectionMembers;
|
|
@ -1,163 +0,0 @@
|
||||||
// @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 isEdited: boolean = false;
|
|
||||||
@observable isSaving: boolean = false;
|
|
||||||
@observable filter: string;
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.users.fetchPage();
|
|
||||||
this.props.collection.fetchUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
if (this.isEdited) {
|
|
||||||
this.props.ui.showToast('Permissions updated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePrivateChange = async (ev: SyntheticInputEvent<*>) => {
|
|
||||||
const { collection } = this.props;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.isEdited = true;
|
|
||||||
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.isEdited = true;
|
|
||||||
this.props.collection.addUser(user);
|
|
||||||
} catch (err) {
|
|
||||||
this.props.ui.showToast('Could not add user');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRemoveUser = user => {
|
|
||||||
try {
|
|
||||||
this.isEdited = true;
|
|
||||||
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);
|
|
|
@ -1,49 +0,0 @@
|
||||||
// @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';
|
|
||||||
import NudeButton from 'components/NudeButton';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
user: User,
|
|
||||||
showRemove: boolean,
|
|
||||||
onRemove: () => void,
|
|
||||||
};
|
|
||||||
|
|
||||||
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={
|
|
||||||
<NudeButton>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Permission = styled(HelpText)`
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 11px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default MemberListItem;
|
|
|
@ -1,3 +0,0 @@
|
||||||
// @flow
|
|
||||||
import CollectionPermissions from './CollectionPermissions';
|
|
||||||
export default CollectionPermissions;
|
|
|
@ -37,6 +37,7 @@ import ErrorOffline from 'scenes/ErrorOffline';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
import RevisionsStore from 'stores/RevisionsStore';
|
import RevisionsStore from 'stores/RevisionsStore';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import Revision from 'models/Revision';
|
import Revision from 'models/Revision';
|
||||||
|
@ -60,6 +61,7 @@ type Props = {
|
||||||
match: Object,
|
match: Object,
|
||||||
history: RouterHistory,
|
history: RouterHistory,
|
||||||
location: Location,
|
location: Location,
|
||||||
|
policies: PoliciesStore,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
revisions: RevisionsStore,
|
revisions: RevisionsStore,
|
||||||
auth: AuthStore,
|
auth: AuthStore,
|
||||||
|
@ -89,6 +91,16 @@ class DocumentScene extends React.Component<Props> {
|
||||||
this.loadEditor();
|
this.loadEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
if (this.document) {
|
||||||
|
const policy = this.props.policies.get(this.document.id);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
this.loadDocument(this.props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
clearTimeout(this.viewTimeout);
|
clearTimeout(this.viewTimeout);
|
||||||
}
|
}
|
||||||
|
@ -100,18 +112,26 @@ class DocumentScene extends React.Component<Props> {
|
||||||
@keydown('m')
|
@keydown('m')
|
||||||
goToMove(ev) {
|
goToMove(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
const document = this.document;
|
||||||
|
if (!document) return;
|
||||||
|
|
||||||
if (this.document && !this.document.isArchived && !this.document.isDraft) {
|
const can = this.props.policies.abilities(document.id);
|
||||||
this.props.history.push(documentMoveUrl(this.document));
|
|
||||||
|
if (can.update) {
|
||||||
|
this.props.history.push(documentMoveUrl(document));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keydown('e')
|
@keydown('e')
|
||||||
goToEdit(ev) {
|
goToEdit(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
const document = this.document;
|
||||||
|
if (!document) return;
|
||||||
|
|
||||||
if (this.document && !this.document.isArchived) {
|
const can = this.props.policies.abilities(document.id);
|
||||||
this.props.history.push(documentEditUrl(this.document));
|
|
||||||
|
if (can.update) {
|
||||||
|
this.props.history.push(documentEditUrl(document));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,6 +336,9 @@ class DocumentScene extends React.Component<Props> {
|
||||||
|
|
||||||
const embedsDisabled = team && !team.documentEmbeds;
|
const embedsDisabled = team && !team.documentEmbeds;
|
||||||
|
|
||||||
|
// this line is only here to make MobX understand that policies are a dependency of this component
|
||||||
|
this.props.policies.abilities(document.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Container
|
<Container
|
||||||
|
@ -428,5 +451,5 @@ const Container = styled(Flex)`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withRouter(
|
export default withRouter(
|
||||||
inject('ui', 'auth', 'documents', 'revisions')(DocumentScene)
|
inject('ui', 'auth', 'documents', 'policies', 'revisions')(DocumentScene)
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,11 +21,14 @@ import DocumentShare from 'scenes/DocumentShare';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import Tooltip from 'components/Tooltip';
|
import Tooltip from 'components/Tooltip';
|
||||||
import Modal from 'components/Modal';
|
import Modal from 'components/Modal';
|
||||||
|
import Fade from 'components/Fade';
|
||||||
import Badge from 'components/Badge';
|
import Badge from 'components/Badge';
|
||||||
import Collaborators from 'components/Collaborators';
|
import Collaborators from 'components/Collaborators';
|
||||||
import { Action, Separator } from 'components/Actions';
|
import { Action, Separator } from 'components/Actions';
|
||||||
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
policies: PoliciesStore,
|
||||||
document: Document,
|
document: Document,
|
||||||
isDraft: boolean,
|
isDraft: boolean,
|
||||||
isEditing: boolean,
|
isEditing: boolean,
|
||||||
|
@ -96,6 +99,7 @@ class Header extends React.Component<Props> {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
document,
|
document,
|
||||||
|
policies,
|
||||||
isEditing,
|
isEditing,
|
||||||
isDraft,
|
isDraft,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
|
@ -104,10 +108,11 @@ class Header extends React.Component<Props> {
|
||||||
publishingIsDisabled,
|
publishingIsDisabled,
|
||||||
auth,
|
auth,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const canShareDocuments =
|
|
||||||
auth.team && auth.team.sharing && !document.isArchived;
|
const can = policies.abilities(document.id);
|
||||||
|
const canShareDocuments = auth.team && auth.team.sharing && can.share;
|
||||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||||
const canEdit = !document.isArchived && !isEditing;
|
const canEdit = can.update && !isEditing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Actions
|
<Actions
|
||||||
|
@ -128,9 +133,13 @@ class Header extends React.Component<Props> {
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Breadcrumb document={document} />
|
<Breadcrumb document={document} />
|
||||||
<Title isHidden={!this.isScrolled} onClick={this.handleClickTitle}>
|
{this.isScrolled && (
|
||||||
{document.title} {document.isArchived && <Badge>Archived</Badge>}
|
<Title onClick={this.handleClickTitle}>
|
||||||
</Title>
|
<Fade>
|
||||||
|
{document.title} {document.isArchived && <Badge>Archived</Badge>}
|
||||||
|
</Fade>
|
||||||
|
</Title>
|
||||||
|
)}
|
||||||
<Wrapper align="center" justify="flex-end">
|
<Wrapper align="center" justify="flex-end">
|
||||||
{!isDraft && !isEditing && <Collaborators document={document} />}
|
{!isDraft && !isEditing && <Collaborators document={document} />}
|
||||||
{isSaving &&
|
{isSaving &&
|
||||||
|
@ -175,18 +184,19 @@ class Header extends React.Component<Props> {
|
||||||
</Action>
|
</Action>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
{isDraft && (
|
{canEdit &&
|
||||||
<Action>
|
isDraft && (
|
||||||
<Button
|
<Action>
|
||||||
onClick={this.handlePublish}
|
<Button
|
||||||
title="Publish document"
|
onClick={this.handlePublish}
|
||||||
disabled={publishingIsDisabled}
|
title="Publish document"
|
||||||
small
|
disabled={publishingIsDisabled}
|
||||||
>
|
small
|
||||||
{isPublishing ? 'Publishing…' : 'Publish'}
|
>
|
||||||
</Button>
|
{isPublishing ? 'Publishing…' : 'Publish'}
|
||||||
</Action>
|
</Button>
|
||||||
)}
|
</Action>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Action>
|
<Action>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
@ -252,6 +262,7 @@ const Status = styled.div`
|
||||||
const Wrapper = styled(Flex)`
|
const Wrapper = styled(Flex)`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
${breakpoint('tablet')`
|
${breakpoint('tablet')`
|
||||||
width: 33.3%;
|
width: 33.3%;
|
||||||
|
@ -293,9 +304,6 @@ const Title = styled.div`
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: opacity 100ms ease-in-out;
|
|
||||||
opacity: ${props => (props.isHidden ? '0' : '1')};
|
|
||||||
cursor: ${props => (props.isHidden ? 'default' : 'pointer')};
|
|
||||||
display: none;
|
display: none;
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
||||||
|
@ -305,4 +313,4 @@ const Title = styled.div`
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject('auth')(Header);
|
export default inject('auth', 'policies')(Header);
|
||||||
|
|
|
@ -37,7 +37,7 @@ class Drafts extends React.Component<Props> {
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Subheading>Documents</Subheading>
|
<Subheading>Documents</Subheading>
|
||||||
<DocumentList documents={drafts} showCollection />
|
<DocumentList documents={drafts} showDraft={false} showCollection />
|
||||||
{showLoading && <ListPlaceholder />}
|
{showLoading && <ListPlaceholder />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -73,6 +73,7 @@ class Invite extends React.Component<Props> {
|
||||||
|
|
||||||
handleCopy = () => {
|
handleCopy = () => {
|
||||||
this.linkCopied = true;
|
this.linkCopied = true;
|
||||||
|
this.props.ui.showToast('A link was copied to your clipboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -145,13 +145,18 @@ export default class BaseStore<T: BaseModel> {
|
||||||
|
|
||||||
this.addPolicies(res.policies);
|
this.addPolicies(res.policies);
|
||||||
return this.add(res.data);
|
return this.add(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.statusCode === 403) {
|
||||||
|
this.remove(id);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async fetchPage(params: ?PaginationParams): Promise<*> {
|
fetchPage = async (params: ?PaginationParams): Promise<*> => {
|
||||||
if (!this.actions.includes('list')) {
|
if (!this.actions.includes('list')) {
|
||||||
throw new Error(`Cannot list ${this.modelName}`);
|
throw new Error(`Cannot list ${this.modelName}`);
|
||||||
}
|
}
|
||||||
|
@ -171,7 +176,7 @@ export default class BaseStore<T: BaseModel> {
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get orderedData(): T[] {
|
get orderedData(): T[] {
|
||||||
|
|
|
@ -173,12 +173,12 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
try {
|
try {
|
||||||
const res = await client.post(`/documents.${request}`, options);
|
const res = await client.post(`/documents.${request}`, options);
|
||||||
invariant(res && res.data, 'Document list not available');
|
invariant(res && res.data, 'Document list not available');
|
||||||
const { data } = res;
|
|
||||||
runInAction('DocumentsStore#fetchNamedPage', () => {
|
runInAction('DocumentsStore#fetchNamedPage', () => {
|
||||||
data.forEach(this.add);
|
res.data.forEach(this.add);
|
||||||
|
this.addPolicies(res.policies);
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
});
|
});
|
||||||
return data;
|
return res.data;
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
}
|
}
|
||||||
|
@ -313,7 +313,10 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
|
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
|
||||||
if (doc) return doc;
|
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
|
||||||
|
if (doc && policy && !options.force) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await client.post('/documents.info', {
|
const res = await client.post('/documents.info', {
|
||||||
id,
|
id,
|
||||||
|
@ -354,7 +357,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
@action
|
@action
|
||||||
duplicate = async (document: Document): * => {
|
duplicate = async (document: Document): * => {
|
||||||
const res = await client.post('/documents.create', {
|
const res = await client.post('/documents.create', {
|
||||||
publish: true,
|
publish: !!document.publishedAt,
|
||||||
parentDocumentId: document.parentDocumentId,
|
parentDocumentId: document.parentDocumentId,
|
||||||
collectionId: document.collectionId,
|
collectionId: document.collectionId,
|
||||||
title: `${document.title} (duplicate)`,
|
title: `${document.title} (duplicate)`,
|
||||||
|
@ -425,6 +428,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
runInAction('Document#archive', () => {
|
runInAction('Document#archive', () => {
|
||||||
invariant(res && res.data, 'Data should be available');
|
invariant(res && res.data, 'Data should be available');
|
||||||
document.updateFromJson(res.data);
|
document.updateFromJson(res.data);
|
||||||
|
this.addPolicies(res.policies);
|
||||||
});
|
});
|
||||||
|
|
||||||
const collection = this.getCollectionForDocument(document);
|
const collection = this.getCollectionForDocument(document);
|
||||||
|
@ -440,6 +444,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
runInAction('Document#restore', () => {
|
runInAction('Document#restore', () => {
|
||||||
invariant(res && res.data, 'Data should be available');
|
invariant(res && res.data, 'Data should be available');
|
||||||
document.updateFromJson(res.data);
|
document.updateFromJson(res.data);
|
||||||
|
this.addPolicies(res.policies);
|
||||||
});
|
});
|
||||||
|
|
||||||
const collection = this.getCollectionForDocument(document);
|
const collection = this.getCollectionForDocument(document);
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
// @flow
|
||||||
|
import invariant from 'invariant';
|
||||||
|
import { action, runInAction } from 'mobx';
|
||||||
|
import { client } from 'utils/ApiClient';
|
||||||
|
import BaseStore from './BaseStore';
|
||||||
|
import RootStore from './RootStore';
|
||||||
|
import Membership from 'models/Membership';
|
||||||
|
import type { PaginationParams } from 'types';
|
||||||
|
|
||||||
|
export default class MembershipsStore extends BaseStore<Membership> {
|
||||||
|
actions = ['create', 'delete'];
|
||||||
|
|
||||||
|
constructor(rootStore: RootStore) {
|
||||||
|
super(rootStore, Membership);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchPage = async (params: ?PaginationParams): Promise<*> => {
|
||||||
|
this.isFetching = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.post(`/collections.memberships`, params);
|
||||||
|
|
||||||
|
invariant(res && res.data, 'Data not available');
|
||||||
|
|
||||||
|
runInAction(`/collections.memberships`, () => {
|
||||||
|
res.data.users.forEach(this.rootStore.users.add);
|
||||||
|
res.data.memberships.forEach(this.add);
|
||||||
|
this.isLoaded = true;
|
||||||
|
});
|
||||||
|
return res.data.users;
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
async create({
|
||||||
|
collectionId,
|
||||||
|
userId,
|
||||||
|
permission,
|
||||||
|
}: {
|
||||||
|
collectionId: string,
|
||||||
|
userId: string,
|
||||||
|
permission: string,
|
||||||
|
}) {
|
||||||
|
const res = await client.post('/collections.add_user', {
|
||||||
|
id: collectionId,
|
||||||
|
userId,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
invariant(res && res.data, 'Membership data should be available');
|
||||||
|
|
||||||
|
res.data.users.forEach(this.rootStore.users.add);
|
||||||
|
res.data.memberships.forEach(this.add);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async delete({
|
||||||
|
collectionId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
collectionId: string,
|
||||||
|
userId: string,
|
||||||
|
}) {
|
||||||
|
await client.post('/collections.remove_user', {
|
||||||
|
id: collectionId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.remove(`${userId}-${collectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
removeCollectionMemberships = (collectionId: string) => {
|
||||||
|
this.data.forEach((membership, key) => {
|
||||||
|
if (key.includes(collectionId)) {
|
||||||
|
this.remove(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ export default class PoliciesStore extends BaseStore<Policy> {
|
||||||
super(rootStore, Policy);
|
super(rootStore, Policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
abilties(id: string) {
|
abilities(id: string) {
|
||||||
const policy = this.get(id);
|
const policy = this.get(id);
|
||||||
return policy ? policy.abilities : {};
|
return policy ? policy.abilities : {};
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import CollectionsStore from './CollectionsStore';
|
||||||
import DocumentsStore from './DocumentsStore';
|
import DocumentsStore from './DocumentsStore';
|
||||||
import EventsStore from './EventsStore';
|
import EventsStore from './EventsStore';
|
||||||
import IntegrationsStore from './IntegrationsStore';
|
import IntegrationsStore from './IntegrationsStore';
|
||||||
|
import MembershipsStore from './MembershipsStore';
|
||||||
import NotificationSettingsStore from './NotificationSettingsStore';
|
import NotificationSettingsStore from './NotificationSettingsStore';
|
||||||
import PoliciesStore from './PoliciesStore';
|
import PoliciesStore from './PoliciesStore';
|
||||||
import RevisionsStore from './RevisionsStore';
|
import RevisionsStore from './RevisionsStore';
|
||||||
|
@ -20,6 +21,7 @@ export default class RootStore {
|
||||||
documents: DocumentsStore;
|
documents: DocumentsStore;
|
||||||
events: EventsStore;
|
events: EventsStore;
|
||||||
integrations: IntegrationsStore;
|
integrations: IntegrationsStore;
|
||||||
|
memberships: MembershipsStore;
|
||||||
notificationSettings: NotificationSettingsStore;
|
notificationSettings: NotificationSettingsStore;
|
||||||
policies: PoliciesStore;
|
policies: PoliciesStore;
|
||||||
revisions: RevisionsStore;
|
revisions: RevisionsStore;
|
||||||
|
@ -35,6 +37,7 @@ export default class RootStore {
|
||||||
this.documents = new DocumentsStore(this);
|
this.documents = new DocumentsStore(this);
|
||||||
this.events = new EventsStore(this);
|
this.events = new EventsStore(this);
|
||||||
this.integrations = new IntegrationsStore(this);
|
this.integrations = new IntegrationsStore(this);
|
||||||
|
this.memberships = new MembershipsStore(this);
|
||||||
this.notificationSettings = new NotificationSettingsStore(this);
|
this.notificationSettings = new NotificationSettingsStore(this);
|
||||||
this.policies = new PoliciesStore(this);
|
this.policies = new PoliciesStore(this);
|
||||||
this.revisions = new RevisionsStore(this);
|
this.revisions = new RevisionsStore(this);
|
||||||
|
@ -50,6 +53,7 @@ export default class RootStore {
|
||||||
this.documents.clear();
|
this.documents.clear();
|
||||||
this.events.clear();
|
this.events.clear();
|
||||||
this.integrations.clear();
|
this.integrations.clear();
|
||||||
|
this.memberships.clear();
|
||||||
this.notificationSettings.clear();
|
this.notificationSettings.clear();
|
||||||
this.policies.clear();
|
this.policies.clear();
|
||||||
this.revisions.clear();
|
this.revisions.clear();
|
||||||
|
|
|
@ -59,6 +59,35 @@ export default class UsersStore extends BaseStore<User> {
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
notInCollection = (collectionId: string, query: string = '') => {
|
||||||
|
const memberships = filter(
|
||||||
|
this.rootStore.memberships.orderedData,
|
||||||
|
member => member.collectionId === collectionId
|
||||||
|
);
|
||||||
|
const userIds = memberships.map(member => member.userId);
|
||||||
|
const users = filter(this.orderedData, user => !userIds.includes(user.id));
|
||||||
|
if (!query) return users;
|
||||||
|
|
||||||
|
return filter(users, user =>
|
||||||
|
user.name.toLowerCase().match(query.toLowerCase())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
inCollection = (collectionId: string, query: string) => {
|
||||||
|
const memberships = filter(
|
||||||
|
this.rootStore.memberships.orderedData,
|
||||||
|
member => member.collectionId === collectionId
|
||||||
|
);
|
||||||
|
const userIds = memberships.map(member => member.userId);
|
||||||
|
const users = filter(this.orderedData, user => userIds.includes(user.id));
|
||||||
|
|
||||||
|
if (!query) return users;
|
||||||
|
|
||||||
|
return filter(users, user =>
|
||||||
|
user.name.toLowerCase().match(query.toLowerCase())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
actionOnUser = async (action: string, user: User) => {
|
actionOnUser = async (action: string, user: User) => {
|
||||||
const res = await client.post(`/users.${action}`, {
|
const res = await client.post(`/users.${action}`, {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|
|
@ -62,6 +62,15 @@ Object {
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`#collections.memberships should require authentication 1`] = `
|
||||||
|
Object {
|
||||||
|
"error": "authentication_required",
|
||||||
|
"message": "Authentication required",
|
||||||
|
"ok": false,
|
||||||
|
"status": 401,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`#collections.remove_user should require user in team 1`] = `
|
exports[`#collections.remove_user should require user in team 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authorization_error",
|
"error": "authorization_error",
|
||||||
|
@ -70,6 +79,15 @@ Object {
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`#collections.update should require authentication 1`] = `
|
||||||
|
Object {
|
||||||
|
"error": "authentication_required",
|
||||||
|
"message": "Authentication required",
|
||||||
|
"ok": false,
|
||||||
|
"status": 401,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`#collections.users should require authentication 1`] = `
|
exports[`#collections.users should require authentication 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authentication_required",
|
"error": "authentication_required",
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
// @flow
|
// @flow
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
|
import { Op } from '../sequelize';
|
||||||
import auth from '../middlewares/authentication';
|
import auth from '../middlewares/authentication';
|
||||||
import pagination from './middlewares/pagination';
|
import pagination from './middlewares/pagination';
|
||||||
import { presentCollection, presentUser, presentPolicies } from '../presenters';
|
import {
|
||||||
|
presentCollection,
|
||||||
|
presentUser,
|
||||||
|
presentPolicies,
|
||||||
|
presentMembership,
|
||||||
|
} from '../presenters';
|
||||||
import { Collection, CollectionUser, Team, Event, User } from '../models';
|
import { Collection, CollectionUser, Team, Event, User } from '../models';
|
||||||
import { ValidationError, InvalidRequestError } from '../errors';
|
import { ValidationError } from '../errors';
|
||||||
import { exportCollections } from '../logistics';
|
import { exportCollections } from '../logistics';
|
||||||
import { archiveCollection, archiveCollections } from '../utils/zip';
|
import { archiveCollection, archiveCollections } from '../utils/zip';
|
||||||
import policy from '../policies';
|
import policy from '../policies';
|
||||||
|
@ -44,7 +50,7 @@ router.post('collections.create', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentCollection(collection),
|
data: presentCollection(collection),
|
||||||
policies: presentPolicies(user, [collection]),
|
policies: presentPolicies(user, [collection]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -54,11 +60,13 @@ router.post('collections.info', auth(), async ctx => {
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const collection = await Collection.findByPk(id);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(id);
|
||||||
authorize(user, 'read', collection);
|
authorize(user, 'read', collection);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentCollection(collection),
|
data: presentCollection(collection),
|
||||||
policies: presentPolicies(user, [collection]),
|
policies: presentPolicies(user, [collection]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -68,23 +76,33 @@ router.post('collections.add_user', auth(), async ctx => {
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
ctx.assertUuid(userId, 'userId is required');
|
ctx.assertUuid(userId, 'userId is required');
|
||||||
|
|
||||||
const collection = await Collection.findByPk(id);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', ctx.state.user.id],
|
||||||
|
}).findByPk(id);
|
||||||
authorize(ctx.state.user, 'update', collection);
|
authorize(ctx.state.user, 'update', collection);
|
||||||
|
|
||||||
if (!collection.private) {
|
|
||||||
throw new InvalidRequestError('Collection must be private to add users');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await User.findByPk(userId);
|
const user = await User.findByPk(userId);
|
||||||
authorize(ctx.state.user, 'read', user);
|
authorize(ctx.state.user, 'read', user);
|
||||||
|
|
||||||
await CollectionUser.create({
|
let membership = await CollectionUser.findOne({
|
||||||
collectionId: id,
|
where: {
|
||||||
userId,
|
collectionId: id,
|
||||||
permission,
|
userId,
|
||||||
createdById: ctx.state.user.id,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
membership = await CollectionUser.create({
|
||||||
|
collectionId: id,
|
||||||
|
userId,
|
||||||
|
permission,
|
||||||
|
createdById: ctx.state.user.id,
|
||||||
|
});
|
||||||
|
} else if (permission) {
|
||||||
|
membership.permission = permission;
|
||||||
|
await membership.save();
|
||||||
|
}
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: 'collections.add_user',
|
name: 'collections.add_user',
|
||||||
userId,
|
userId,
|
||||||
|
@ -96,7 +114,10 @@ router.post('collections.add_user', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
data: {
|
||||||
|
users: [presentUser(user)],
|
||||||
|
memberships: [presentMembership(membership)],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -105,13 +126,11 @@ router.post('collections.remove_user', auth(), async ctx => {
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
ctx.assertUuid(userId, 'userId is required');
|
ctx.assertUuid(userId, 'userId is required');
|
||||||
|
|
||||||
const collection = await Collection.findByPk(id);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', ctx.state.user.id],
|
||||||
|
}).findByPk(id);
|
||||||
authorize(ctx.state.user, 'update', collection);
|
authorize(ctx.state.user, 'update', collection);
|
||||||
|
|
||||||
if (!collection.private) {
|
|
||||||
throw new InvalidRequestError('Collection must be private to remove users');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await User.findByPk(userId);
|
const user = await User.findByPk(userId);
|
||||||
authorize(ctx.state.user, 'read', user);
|
authorize(ctx.state.user, 'read', user);
|
||||||
|
|
||||||
|
@ -132,12 +151,16 @@ router.post('collections.remove_user', auth(), async ctx => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DEPRECATED: Use collection.memberships which has pagination, filtering and permissions
|
||||||
router.post('collections.users', auth(), async ctx => {
|
router.post('collections.users', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
|
||||||
const collection = await Collection.findByPk(id);
|
const user = ctx.state.user;
|
||||||
authorize(ctx.state.user, 'read', collection);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(id);
|
||||||
|
authorize(user, 'read', collection);
|
||||||
|
|
||||||
const users = await collection.getUsers();
|
const users = await collection.getUsers();
|
||||||
|
|
||||||
|
@ -146,12 +169,69 @@ router.post('collections.users', auth(), async ctx => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('collections.memberships', auth(), pagination(), async ctx => {
|
||||||
|
const { id, query, permission } = ctx.body;
|
||||||
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(id);
|
||||||
|
authorize(user, 'read', collection);
|
||||||
|
|
||||||
|
let where = {
|
||||||
|
collectionId: id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let userWhere;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
userWhere = {
|
||||||
|
name: {
|
||||||
|
[Op.iLike]: `%${query}%`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission) {
|
||||||
|
where = {
|
||||||
|
...where,
|
||||||
|
permission,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await CollectionUser.findAll({
|
||||||
|
where,
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'user',
|
||||||
|
where: userWhere,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data: {
|
||||||
|
memberships: memberships.map(presentMembership),
|
||||||
|
users: memberships.map(membership => presentUser(membership.user)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post('collections.export', auth(), async ctx => {
|
router.post('collections.export', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const collection = await Collection.findByPk(id);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(id);
|
||||||
authorize(user, 'export', collection);
|
authorize(user, 'export', collection);
|
||||||
|
|
||||||
const filePath = await archiveCollection(collection);
|
const filePath = await archiveCollection(collection);
|
||||||
|
@ -207,15 +287,20 @@ router.post('collections.exportAll', auth(), async ctx => {
|
||||||
router.post('collections.update', auth(), async ctx => {
|
router.post('collections.update', auth(), async ctx => {
|
||||||
const { id, name, description, color } = ctx.body;
|
const { id, name, description, color } = ctx.body;
|
||||||
const isPrivate = ctx.body.private;
|
const isPrivate = ctx.body.private;
|
||||||
|
|
||||||
ctx.assertPresent(name, 'name is required');
|
ctx.assertPresent(name, 'name is required');
|
||||||
if (color)
|
|
||||||
|
if (color) {
|
||||||
ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)');
|
ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)');
|
||||||
|
}
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const collection = await Collection.findByPk(id);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(id);
|
||||||
authorize(user, 'update', collection);
|
authorize(user, 'update', collection);
|
||||||
|
|
||||||
|
// we're making this collection private right now, ensure that the current
|
||||||
|
// user has a read-write membership so that at least they can edit it
|
||||||
if (isPrivate && !collection.private) {
|
if (isPrivate && !collection.private) {
|
||||||
await CollectionUser.findOrCreate({
|
await CollectionUser.findOrCreate({
|
||||||
where: {
|
where: {
|
||||||
|
@ -229,6 +314,8 @@ router.post('collections.update', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPrivacyChanged = isPrivate !== collection.private;
|
||||||
|
|
||||||
collection.name = name;
|
collection.name = name;
|
||||||
collection.description = description;
|
collection.description = description;
|
||||||
collection.color = color;
|
collection.color = color;
|
||||||
|
@ -244,6 +331,16 @@ router.post('collections.update', auth(), async ctx => {
|
||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// must reload to update collection membership for correct policy calculation
|
||||||
|
// if the privacy level has changed. Otherwise skip this query for speed.
|
||||||
|
if (isPrivacyChanged) {
|
||||||
|
await collection.reload({
|
||||||
|
scope: {
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentCollection(collection),
|
data: presentCollection(collection),
|
||||||
policies: presentPolicies(user, [collection]),
|
policies: presentPolicies(user, [collection]),
|
||||||
|
@ -252,9 +349,10 @@ router.post('collections.update', auth(), async ctx => {
|
||||||
|
|
||||||
router.post('collections.list', auth(), pagination(), async ctx => {
|
router.post('collections.list', auth(), pagination(), async ctx => {
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
|
||||||
const collectionIds = await user.collectionIds();
|
const collectionIds = await user.collectionIds();
|
||||||
let collections = await Collection.findAll({
|
let collections = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findAll({
|
||||||
where: {
|
where: {
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
id: collectionIds,
|
id: collectionIds,
|
||||||
|
@ -264,15 +362,10 @@ router.post('collections.list', auth(), pagination(), async ctx => {
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
|
||||||
collections.map(async collection => await presentCollection(collection))
|
|
||||||
);
|
|
||||||
const policies = presentPolicies(user, collections);
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data,
|
data: collections.map(presentCollection),
|
||||||
policies,
|
policies: presentPolicies(user, collections),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -281,7 +374,9 @@ router.post('collections.delete', auth(), async ctx => {
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
|
||||||
const collection = await Collection.findByPk(id);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(id);
|
||||||
authorize(user, 'delete', collection);
|
authorize(user, 'delete', collection);
|
||||||
|
|
||||||
const total = await Collection.count();
|
const total = await Collection.count();
|
||||||
|
|
|
@ -3,7 +3,7 @@ import TestServer from 'fetch-test-server';
|
||||||
import app from '../app';
|
import app from '../app';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser, buildCollection } from '../test/factories';
|
import { buildUser, buildCollection } from '../test/factories';
|
||||||
import { Collection } from '../models';
|
import { Collection, CollectionUser } from '../models';
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
|
|
||||||
beforeEach(flushdb);
|
beforeEach(flushdb);
|
||||||
|
@ -28,6 +28,8 @@ describe('#collections.list', async () => {
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.length).toEqual(1);
|
expect(body.data.length).toEqual(1);
|
||||||
expect(body.data[0].id).toEqual(collection.id);
|
expect(body.data[0].id).toEqual(collection.id);
|
||||||
|
expect(body.policies.length).toEqual(1);
|
||||||
|
expect(body.policies[0].abilities.read).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not return private collections not a member of', async () => {
|
it('should not return private collections not a member of', async () => {
|
||||||
|
@ -60,11 +62,13 @@ describe('#collections.list', async () => {
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.length).toEqual(2);
|
expect(body.data.length).toEqual(2);
|
||||||
|
expect(body.policies.length).toEqual(2);
|
||||||
|
expect(body.policies[0].abilities.read).toEqual(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#collections.export', async () => {
|
describe('#collections.export', async () => {
|
||||||
it('should require user to be a member', async () => {
|
it('should now allow export of private collection not a member', async () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
const collection = await buildCollection({
|
const collection = await buildCollection({
|
||||||
private: true,
|
private: true,
|
||||||
|
@ -77,6 +81,25 @@ describe('#collections.export', async () => {
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow export of private collection', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.export', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const res = await server.post('/api/collections.export');
|
const res = await server.post('/api/collections.export');
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
@ -272,12 +295,25 @@ describe('#collections.remove_user', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#collections.users', async () => {
|
describe('#collections.users', async () => {
|
||||||
it('should return members in private collection', async () => {
|
it('should return users in private collection', async () => {
|
||||||
const { collection, user } = await seed();
|
const { collection, user } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
const res = await server.post('/api/collections.users', {
|
const res = await server.post('/api/collections.users', {
|
||||||
body: { token: user.getJwtToken(), id: collection.id },
|
body: { token: user.getJwtToken(), id: collection.id },
|
||||||
});
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
|
@ -298,6 +334,109 @@ describe('#collections.users', async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#collections.memberships', async () => {
|
||||||
|
it('should return members in private collection', async () => {
|
||||||
|
const { collection, user } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.memberships', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.users.length).toEqual(1);
|
||||||
|
expect(body.data.users[0].id).toEqual(user.id);
|
||||||
|
expect(body.data.memberships.length).toEqual(1);
|
||||||
|
expect(body.data.memberships[0].permission).toEqual('read_write');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow filtering members in collection by name', async () => {
|
||||||
|
const { collection, user } = await seed();
|
||||||
|
const user2 = await buildUser({ name: "Won't find" });
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user2.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user2.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.memberships', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: collection.id,
|
||||||
|
query: user.name.slice(0, 3),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.users.length).toEqual(1);
|
||||||
|
expect(body.data.users[0].id).toEqual(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow filtering members in collection by permission', async () => {
|
||||||
|
const { collection, user } = await seed();
|
||||||
|
const user2 = await buildUser();
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user2.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user2.id,
|
||||||
|
permission: 'maintainer',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.memberships', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: collection.id,
|
||||||
|
permission: 'maintainer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.users.length).toEqual(1);
|
||||||
|
expect(body.data.users[0].id).toEqual(user2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const res = await server.post('/api/collections.memberships');
|
||||||
|
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.memberships', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#collections.info', async () => {
|
describe('#collections.info', async () => {
|
||||||
it('should return collection', async () => {
|
it('should return collection', async () => {
|
||||||
const { user, collection } = await seed();
|
const { user, collection } = await seed();
|
||||||
|
@ -321,6 +460,27 @@ describe('#collections.info', async () => {
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow user member of collection', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.info', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.id).toEqual(collection.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const res = await server.post('/api/collections.info');
|
const res = await server.post('/api/collections.info');
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
@ -362,6 +522,128 @@ describe('#collections.create', async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#collections.update', async () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const collection = await buildCollection();
|
||||||
|
const res = await server.post('/api/collections.update', {
|
||||||
|
body: { id: collection.id, name: 'Test' },
|
||||||
|
});
|
||||||
|
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.update', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows editing non-private collection', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
const res = await server.post('/api/collections.update', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.name).toBe('Test');
|
||||||
|
expect(body.policies.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows editing from non-private to private collection', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
const res = await server.post('/api/collections.update', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: collection.id,
|
||||||
|
private: true,
|
||||||
|
name: 'Test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.name).toBe('Test');
|
||||||
|
expect(body.data.private).toBe(true);
|
||||||
|
|
||||||
|
// ensure we return with a write level policy
|
||||||
|
expect(body.policies.length).toBe(1);
|
||||||
|
expect(body.policies[0].abilities.update).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows editing from private to non-private collection', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.update', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: collection.id,
|
||||||
|
private: false,
|
||||||
|
name: 'Test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.name).toBe('Test');
|
||||||
|
expect(body.data.private).toBe(false);
|
||||||
|
|
||||||
|
// ensure we return with a write level policy
|
||||||
|
expect(body.policies.length).toBe(1);
|
||||||
|
expect(body.policies[0].abilities.update).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows editing by read-write collection user', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.update', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.name).toBe('Test');
|
||||||
|
expect(body.policies.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow editing by read-only collection user', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/collections.update', {
|
||||||
|
body: { token: user.getJwtToken(), id: collection.id, name: 'Test' },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#collections.delete', async () => {
|
describe('#collections.delete', async () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const res = await server.post('/api/collections.delete');
|
const res = await server.post('/api/collections.delete');
|
||||||
|
@ -380,7 +662,7 @@ describe('#collections.delete', async () => {
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not delete last collection', async () => {
|
it('should not allow deleting last collection', async () => {
|
||||||
const { user, collection } = await seed();
|
const { user, collection } = await seed();
|
||||||
const res = await server.post('/api/collections.delete', {
|
const res = await server.post('/api/collections.delete', {
|
||||||
body: { token: user.getJwtToken(), id: collection.id },
|
body: { token: user.getJwtToken(), id: collection.id },
|
||||||
|
|
|
@ -53,7 +53,9 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
||||||
ctx.assertUuid(collectionId, 'collection must be a UUID');
|
ctx.assertUuid(collectionId, 'collection must be a UUID');
|
||||||
|
|
||||||
where = { ...where, collectionId };
|
where = { ...where, collectionId };
|
||||||
const collection = await Collection.findByPk(collectionId);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(collectionId);
|
||||||
authorize(user, 'read', collection);
|
authorize(user, 'read', collection);
|
||||||
|
|
||||||
// otherwise, filter by all collections the user has access to
|
// otherwise, filter by all collections the user has access to
|
||||||
|
@ -78,7 +80,12 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
||||||
|
|
||||||
// add the users starred state to the response by default
|
// add the users starred state to the response by default
|
||||||
const starredScope = { method: ['withStarred', user.id] };
|
const starredScope = { method: ['withStarred', user.id] };
|
||||||
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
const collectionScope = { method: ['withCollection', user.id] };
|
||||||
|
const documents = await Document.scope(
|
||||||
|
'defaultScope',
|
||||||
|
starredScope,
|
||||||
|
collectionScope
|
||||||
|
).findAll({
|
||||||
where,
|
where,
|
||||||
order: [[sort, direction]],
|
order: [[sort, direction]],
|
||||||
offset: ctx.state.pagination.offset,
|
offset: ctx.state.pagination.offset,
|
||||||
|
@ -99,18 +106,24 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('documents.pinned', auth(), pagination(), async ctx => {
|
router.post('documents.pinned', auth(), pagination(), async ctx => {
|
||||||
const { sort = 'updatedAt' } = ctx.body;
|
const { collectionId, sort = 'updatedAt' } = ctx.body;
|
||||||
const collectionId = ctx.body.collection;
|
|
||||||
let direction = ctx.body.direction;
|
let direction = ctx.body.direction;
|
||||||
if (direction !== 'ASC') direction = 'DESC';
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
ctx.assertUuid(collectionId, 'collection is required');
|
ctx.assertUuid(collectionId, 'collectionId is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const collection = await Collection.findByPk(collectionId);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(collectionId);
|
||||||
authorize(user, 'read', collection);
|
authorize(user, 'read', collection);
|
||||||
|
|
||||||
const starredScope = { method: ['withStarred', user.id] };
|
const starredScope = { method: ['withStarred', user.id] };
|
||||||
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
const collectionScope = { method: ['withCollection', user.id] };
|
||||||
|
const documents = await Document.scope(
|
||||||
|
'defaultScope',
|
||||||
|
starredScope,
|
||||||
|
collectionScope
|
||||||
|
).findAll({
|
||||||
where: {
|
where: {
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
collectionId,
|
collectionId,
|
||||||
|
@ -269,7 +282,11 @@ router.post('documents.drafts', auth(), pagination(), async ctx => {
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const collectionIds = await user.collectionIds();
|
const collectionIds = await user.collectionIds();
|
||||||
|
|
||||||
const documents = await Document.findAll({
|
const collectionScope = { method: ['withCollection', user.id] };
|
||||||
|
const documents = await Document.scope(
|
||||||
|
'defaultScope',
|
||||||
|
collectionScope
|
||||||
|
).findAll({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
collectionId: collectionIds,
|
collectionId: collectionIds,
|
||||||
|
@ -324,7 +341,10 @@ router.post('documents.info', auth({ required: false }), async ctx => {
|
||||||
}
|
}
|
||||||
document = share.document;
|
document = share.document;
|
||||||
} else {
|
} else {
|
||||||
document = await Document.findByPk(id);
|
document = await Document.findByPk(
|
||||||
|
id,
|
||||||
|
user ? { userId: user.id } : undefined
|
||||||
|
);
|
||||||
authorize(user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -341,8 +361,9 @@ router.post('documents.revision', auth(), async ctx => {
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||||
|
|
||||||
const document = await Document.findByPk(id);
|
const user = ctx.state.user;
|
||||||
authorize(ctx.state.user, 'read', document);
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
const revision = await Revision.findOne({
|
const revision = await Revision.findOne({
|
||||||
where: {
|
where: {
|
||||||
|
@ -361,9 +382,10 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
|
||||||
let { id, sort = 'updatedAt', direction } = ctx.body;
|
let { id, sort = 'updatedAt', direction } = ctx.body;
|
||||||
if (direction !== 'ASC') direction = 'DESC';
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
const document = await Document.findByPk(id);
|
|
||||||
|
|
||||||
authorize(ctx.state.user, 'read', document);
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
const revisions = await Revision.findAll({
|
const revisions = await Revision.findAll({
|
||||||
where: { documentId: id },
|
where: { documentId: id },
|
||||||
|
@ -383,7 +405,7 @@ router.post('documents.restore', auth(), async ctx => {
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(id);
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
|
|
||||||
if (document.archivedAt) {
|
if (document.archivedAt) {
|
||||||
authorize(user, 'unarchive', document);
|
authorize(user, 'unarchive', document);
|
||||||
|
@ -439,7 +461,9 @@ router.post('documents.search', auth(), pagination(), async ctx => {
|
||||||
if (collectionId) {
|
if (collectionId) {
|
||||||
ctx.assertUuid(collectionId, 'collectionId must be a UUID');
|
ctx.assertUuid(collectionId, 'collectionId must be a UUID');
|
||||||
|
|
||||||
const collection = await Collection.findByPk(collectionId);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(collectionId);
|
||||||
authorize(user, 'read', collection);
|
authorize(user, 'read', collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -486,10 +510,10 @@ router.post('documents.search', auth(), pagination(), async ctx => {
|
||||||
router.post('documents.pin', auth(), async ctx => {
|
router.post('documents.pin', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
const user = ctx.state.user;
|
|
||||||
const document = await Document.findByPk(id);
|
|
||||||
|
|
||||||
authorize(user, 'update', document);
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
|
authorize(user, 'pin', document);
|
||||||
|
|
||||||
document.pinnedById = user.id;
|
document.pinnedById = user.id;
|
||||||
await document.save();
|
await document.save();
|
||||||
|
@ -513,10 +537,10 @@ router.post('documents.pin', auth(), async ctx => {
|
||||||
router.post('documents.unpin', auth(), async ctx => {
|
router.post('documents.unpin', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
const user = ctx.state.user;
|
|
||||||
const document = await Document.findByPk(id);
|
|
||||||
|
|
||||||
authorize(user, 'update', document);
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
|
authorize(user, 'unpin', document);
|
||||||
|
|
||||||
document.pinnedById = null;
|
document.pinnedById = null;
|
||||||
await document.save();
|
await document.save();
|
||||||
|
@ -540,9 +564,9 @@ router.post('documents.unpin', auth(), async ctx => {
|
||||||
router.post('documents.star', auth(), async ctx => {
|
router.post('documents.star', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
const user = ctx.state.user;
|
|
||||||
const document = await Document.findByPk(id);
|
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
authorize(user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
await Star.findOrCreate({
|
await Star.findOrCreate({
|
||||||
|
@ -563,9 +587,9 @@ router.post('documents.star', auth(), async ctx => {
|
||||||
router.post('documents.unstar', auth(), async ctx => {
|
router.post('documents.unstar', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
const user = ctx.state.user;
|
|
||||||
const document = await Document.findByPk(id);
|
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
authorize(user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
await Star.destroy({
|
await Star.destroy({
|
||||||
|
@ -574,7 +598,7 @@ router.post('documents.unstar', auth(), async ctx => {
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: 'documents.unstar',
|
name: 'documents.unstar',
|
||||||
modelId: document.id,
|
documentId: document.id,
|
||||||
collectionId: document.collectionId,
|
collectionId: document.collectionId,
|
||||||
teamId: document.teamId,
|
teamId: document.teamId,
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
|
@ -602,7 +626,9 @@ router.post('documents.create', auth(), async ctx => {
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
authorize(user, 'create', Document);
|
authorize(user, 'create', Document);
|
||||||
|
|
||||||
const collection = await Collection.findOne({
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findOne({
|
||||||
where: {
|
where: {
|
||||||
id: collectionId,
|
id: collectionId,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
|
@ -618,7 +644,7 @@ router.post('documents.create', auth(), async ctx => {
|
||||||
collectionId: collection.id,
|
collectionId: collection.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
authorize(user, 'read', parentDocument);
|
authorize(user, 'read', parentDocument, { collection });
|
||||||
}
|
}
|
||||||
|
|
||||||
let document = await Document.create({
|
let document = await Document.create({
|
||||||
|
@ -662,6 +688,7 @@ router.post('documents.create', auth(), async ctx => {
|
||||||
document = await Document.findOne({
|
document = await Document.findOne({
|
||||||
where: { id: document.id, publishedAt: document.publishedAt },
|
where: { id: document.id, publishedAt: document.publishedAt },
|
||||||
});
|
});
|
||||||
|
document.collection = collection;
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(document),
|
data: await presentDocument(document),
|
||||||
|
@ -685,9 +712,8 @@ router.post('documents.update', auth(), async ctx => {
|
||||||
if (append) ctx.assertPresent(text, 'Text is required while appending');
|
if (append) ctx.assertPresent(text, 'Text is required while appending');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(id);
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
|
authorize(user, 'update', document);
|
||||||
authorize(ctx.state.user, 'update', document);
|
|
||||||
|
|
||||||
if (lastRevision && lastRevision !== document.revisionCount) {
|
if (lastRevision && lastRevision !== document.revisionCount) {
|
||||||
throw new InvalidRequestError('Document has changed since last revision');
|
throw new InvalidRequestError('Document has changed since last revision');
|
||||||
|
@ -702,6 +728,7 @@ router.post('documents.update', auth(), async ctx => {
|
||||||
document.text = text;
|
document.text = text;
|
||||||
}
|
}
|
||||||
document.lastModifiedById = user.id;
|
document.lastModifiedById = user.id;
|
||||||
|
const { collection } = document;
|
||||||
|
|
||||||
let transaction;
|
let transaction;
|
||||||
try {
|
try {
|
||||||
|
@ -746,6 +773,8 @@ router.post('documents.update', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.collection = collection;
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(document),
|
data: await presentDocument(document),
|
||||||
policies: presentPolicies(user, [document]),
|
policies: presentPolicies(user, [document]),
|
||||||
|
@ -770,12 +799,10 @@ router.post('documents.move', auth(), async ctx => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(id);
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
|
const { collection } = document;
|
||||||
authorize(user, 'move', document);
|
authorize(user, 'move', document);
|
||||||
|
|
||||||
const collection = await Collection.findByPk(collectionId);
|
|
||||||
authorize(user, 'update', collection);
|
|
||||||
|
|
||||||
if (collection.type !== 'atlas' && parentDocumentId) {
|
if (collection.type !== 'atlas' && parentDocumentId) {
|
||||||
throw new InvalidRequestError(
|
throw new InvalidRequestError(
|
||||||
'Document cannot be nested in this collection type'
|
'Document cannot be nested in this collection type'
|
||||||
|
@ -783,7 +810,7 @@ router.post('documents.move', auth(), async ctx => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentDocumentId) {
|
if (parentDocumentId) {
|
||||||
const parent = await Document.findByPk(parentDocumentId);
|
const parent = await Document.findByPk(parentDocumentId, user.id);
|
||||||
authorize(user, 'update', parent);
|
authorize(user, 'update', parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -814,7 +841,7 @@ router.post('documents.archive', auth(), async ctx => {
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(id);
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
authorize(user, 'archive', document);
|
authorize(user, 'archive', document);
|
||||||
|
|
||||||
await document.archive(user.id);
|
await document.archive(user.id);
|
||||||
|
@ -840,7 +867,7 @@ router.post('documents.delete', auth(), async ctx => {
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(id);
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
authorize(user, 'delete', document);
|
authorize(user, 'delete', document);
|
||||||
|
|
||||||
await document.delete();
|
await document.delete();
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '../app';
|
import app from '../app';
|
||||||
import { Document, View, Star, Revision, Backlink } from '../models';
|
import {
|
||||||
|
Document,
|
||||||
|
View,
|
||||||
|
Star,
|
||||||
|
Revision,
|
||||||
|
Backlink,
|
||||||
|
CollectionUser,
|
||||||
|
} from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import {
|
import {
|
||||||
buildShare,
|
buildShare,
|
||||||
|
@ -274,6 +281,30 @@ describe('#documents.list', async () => {
|
||||||
expect(body.data.length).toEqual(1);
|
expect(body.data.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow filtering to private collection', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.list', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
collection: collection.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return backlinks', async () => {
|
it('should return backlinks', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
const anotherDoc = await buildDocument({
|
const anotherDoc = await buildDocument({
|
||||||
|
@ -308,6 +339,64 @@ describe('#documents.list', async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#documents.pinned', async () => {
|
||||||
|
it('should return pinned documents', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
document.pinnedById = user.id;
|
||||||
|
await document.save();
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.pinned', {
|
||||||
|
body: { token: user.getJwtToken(), collectionId: document.collectionId },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
expect(body.data[0].id).toEqual(document.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return pinned documents in private collections member of', async () => {
|
||||||
|
const { user, collection, document } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
document.pinnedById = user.id;
|
||||||
|
await document.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.pinned', {
|
||||||
|
body: { token: user.getJwtToken(), collectionId: document.collectionId },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
expect(body.data[0].id).toEqual(document.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return pinned 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.pinned', {
|
||||||
|
body: { token: user.getJwtToken(), collectionId: collection.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const res = await server.post('/api/documents.pinned');
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#documents.drafts', async () => {
|
describe('#documents.drafts', async () => {
|
||||||
it('should return unpublished documents', async () => {
|
it('should return unpublished documents', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
|
@ -575,6 +664,39 @@ describe('#documents.search', async () => {
|
||||||
expect(body.data[0].document.id).toEqual(document.id);
|
expect(body.data[0].document.id).toEqual(document.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return documents for a specific private collection', async () => {
|
||||||
|
const { user, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await buildDocument({
|
||||||
|
title: 'search term',
|
||||||
|
text: 'search term',
|
||||||
|
teamId: user.teamId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.search', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
query: 'search term',
|
||||||
|
collectionId: collection.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
expect(body.data[0].document.id).toEqual(document.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return documents for a specific collection', async () => {
|
it('should return documents for a specific collection', async () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
const collection = await buildCollection();
|
const collection = await buildCollection();
|
||||||
|
@ -817,6 +939,7 @@ describe('#documents.pin', async () => {
|
||||||
body: { token: user.getJwtToken(), id: document.id },
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.pinned).toEqual(true);
|
expect(body.data.pinned).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -935,6 +1058,7 @@ describe('#documents.unpin', async () => {
|
||||||
body: { token: user.getJwtToken(), id: document.id },
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.pinned).toEqual(false);
|
expect(body.data.pinned).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1036,7 +1160,7 @@ describe('#documents.create', async () => {
|
||||||
const newDocument = await Document.findByPk(body.data.id);
|
const newDocument = await Document.findByPk(body.data.id);
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(newDocument.parentDocumentId).toBe(null);
|
expect(newDocument.parentDocumentId).toBe(null);
|
||||||
expect(newDocument.collection.id).toBe(collection.id);
|
expect(newDocument.collectionId).toBe(collection.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow very long titles', async () => {
|
it('should not allow very long titles', async () => {
|
||||||
|
@ -1126,6 +1250,38 @@ describe('#documents.update', async () => {
|
||||||
expect(body.data.text).toBe('Updated text');
|
expect(body.data.text).toBe('Updated text');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow publishing document in private collection', async () => {
|
||||||
|
const { user, collection, document } = await seed();
|
||||||
|
document.publishedAt = null;
|
||||||
|
await document.save();
|
||||||
|
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.update', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: document.id,
|
||||||
|
title: 'Updated title',
|
||||||
|
text: 'Updated text',
|
||||||
|
lastRevision: document.revision,
|
||||||
|
publish: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.publishedAt).toBeTruthy();
|
||||||
|
expect(body.policies[0].abilities.update).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not edit archived document', async () => {
|
it('should not edit archived document', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
await document.archive();
|
await document.archive();
|
||||||
|
@ -1217,23 +1373,54 @@ describe('#documents.update', async () => {
|
||||||
expect(body.data.title).toBe('Updated title');
|
expect(body.data.title).toBe('Updated title');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('allows editing by read-write collection user', async () => {
|
||||||
const { document } = await seed();
|
const { user, document, collection } = await seed();
|
||||||
const res = await server.post('/api/documents.update', {
|
collection.private = true;
|
||||||
body: { id: document.id, text: 'Updated' },
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
permission: 'read_write',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.update', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: document.id,
|
||||||
|
text: 'Changed text',
|
||||||
|
lastRevision: document.revision,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|
||||||
expect(res.status).toEqual(401);
|
expect(res.status).toEqual(200);
|
||||||
expect(body).toMatchSnapshot();
|
expect(body.data.text).toBe('Changed text');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require authorization', async () => {
|
it('does not allow editing by read-only collection user', async () => {
|
||||||
const { document } = await seed();
|
const { user, document, collection } = await seed();
|
||||||
const user = await buildUser();
|
collection.private = true;
|
||||||
const res = await server.post('/api/documents.update', {
|
await collection.save();
|
||||||
body: { token: user.getJwtToken(), id: document.id, text: 'Updated' },
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
permission: 'read',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.update', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: document.id,
|
||||||
|
text: 'Changed text',
|
||||||
|
lastRevision: document.revision,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1272,6 +1459,26 @@ describe('#documents.update', async () => {
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
expect(body).toMatchSnapshot();
|
expect(body).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const res = await server.post('/api/documents.update', {
|
||||||
|
body: { id: document.id, text: 'Updated' },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authorization', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post('/api/documents.update', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id, text: 'Updated' },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#documents.archive', async () => {
|
describe('#documents.archive', async () => {
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import { escapeRegExp } from 'lodash';
|
import { escapeRegExp } from 'lodash';
|
||||||
import { AuthenticationError, InvalidRequestError } from '../errors';
|
import { AuthenticationError, InvalidRequestError } from '../errors';
|
||||||
import { Authentication, Document, User, Team } from '../models';
|
import { Authentication, Document, User, Team, Collection } from '../models';
|
||||||
import { presentSlackAttachment } from '../presenters';
|
import { presentSlackAttachment } from '../presenters';
|
||||||
import * as Slack from '../slack';
|
import * as Slack from '../slack';
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
@ -85,12 +85,14 @@ router.post('hooks.interactive', async ctx => {
|
||||||
});
|
});
|
||||||
if (!document) throw new InvalidRequestError('Invalid document');
|
if (!document) throw new InvalidRequestError('Invalid document');
|
||||||
|
|
||||||
|
const collection = await Collection.findByPk(document.collectionId);
|
||||||
|
|
||||||
// respond with a public message that will be posted in the original channel
|
// respond with a public message that will be posted in the original channel
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
response_type: 'in_channel',
|
response_type: 'in_channel',
|
||||||
replace_original: false,
|
replace_original: false,
|
||||||
attachments: [
|
attachments: [
|
||||||
presentSlackAttachment(document, team, document.getSummary()),
|
presentSlackAttachment(document, collection, team, document.getSummary()),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -158,6 +160,7 @@ router.post('hooks.slack', async ctx => {
|
||||||
attachments.push(
|
attachments.push(
|
||||||
presentSlackAttachment(
|
presentSlackAttachment(
|
||||||
result.document,
|
result.document,
|
||||||
|
result.document.collection,
|
||||||
team,
|
team,
|
||||||
queryIsInTitle ? undefined : result.context,
|
queryIsInTitle ? undefined : result.context,
|
||||||
process.env.SLACK_MESSAGE_ACTIONS
|
process.env.SLACK_MESSAGE_ACTIONS
|
||||||
|
|
|
@ -60,7 +60,7 @@ router.post('shares.create', auth(), async ctx => {
|
||||||
ctx.assertPresent(documentId, 'documentId is required');
|
ctx.assertPresent(documentId, 'documentId is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(documentId);
|
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||||
const team = await Team.findByPk(user.teamId);
|
const team = await Team.findByPk(user.teamId);
|
||||||
authorize(user, 'share', document);
|
authorize(user, 'share', document);
|
||||||
authorize(user, 'share', team);
|
authorize(user, 'share', team);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '../app';
|
import app from '../app';
|
||||||
|
import { CollectionUser } from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser, buildShare } from '../test/factories';
|
import { buildUser, buildShare } from '../test/factories';
|
||||||
|
|
||||||
|
@ -110,6 +111,27 @@ describe('#shares.create', async () => {
|
||||||
expect(body.data.documentTitle).toBe(document.title);
|
expect(body.data.documentTitle).toBe(document.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow creating a share record for document in read-only collection', async () => {
|
||||||
|
const { user, document, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/shares.create', {
|
||||||
|
body: { token: user.getJwtToken(), documentId: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.documentTitle).toBe(document.title);
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow creating a share record if link previously revoked', async () => {
|
it('should allow creating a share record if link previously revoked', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
|
import { Op } from '../sequelize';
|
||||||
import {
|
import {
|
||||||
makePolicy,
|
makePolicy,
|
||||||
getSignature,
|
getSignature,
|
||||||
|
@ -20,12 +21,24 @@ const { authorize } = policy;
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post('users.list', auth(), pagination(), async ctx => {
|
router.post('users.list', auth(), pagination(), async ctx => {
|
||||||
|
const { query } = ctx.body;
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
|
||||||
|
let where = {
|
||||||
|
teamId: user.teamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
where = {
|
||||||
|
...where,
|
||||||
|
name: {
|
||||||
|
[Op.iLike]: `%${query}%`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const users = await User.findAll({
|
const users = await User.findAll({
|
||||||
where: {
|
where,
|
||||||
teamId: user.teamId,
|
|
||||||
},
|
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
offset: ctx.state.pagination.offset,
|
offset: ctx.state.pagination.offset,
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
|
|
|
@ -11,6 +11,22 @@ beforeEach(flushdb);
|
||||||
afterAll(server.close);
|
afterAll(server.close);
|
||||||
|
|
||||||
describe('#users.list', async () => {
|
describe('#users.list', async () => {
|
||||||
|
it('should allow filtering by user name', async () => {
|
||||||
|
const user = await buildUser({ name: 'Tester' });
|
||||||
|
|
||||||
|
const res = await server.post('/api/users.list', {
|
||||||
|
body: {
|
||||||
|
query: 'test',
|
||||||
|
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(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return teams paginated user list', async () => {
|
it('should return teams paginated user list', async () => {
|
||||||
const { admin, user } = await seed();
|
const { admin, user } = await seed();
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ router.post('views.list', auth(), async ctx => {
|
||||||
ctx.assertUuid(documentId, 'documentId is required');
|
ctx.assertUuid(documentId, 'documentId is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(documentId);
|
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||||
authorize(user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
const views = await View.findAll({
|
const views = await View.findAll({
|
||||||
|
@ -37,7 +37,7 @@ router.post('views.create', auth(), async ctx => {
|
||||||
ctx.assertUuid(documentId, 'documentId is required');
|
ctx.assertUuid(documentId, 'documentId is required');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findByPk(documentId);
|
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||||
authorize(user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
await View.increment({ documentId, userId: user.id });
|
await View.increment({ documentId, userId: user.id });
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '../app';
|
import app from '../app';
|
||||||
import { View } from '../models';
|
import { View, CollectionUser } from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser } from '../test/factories';
|
import { buildUser } from '../test/factories';
|
||||||
|
|
||||||
|
@ -25,6 +25,30 @@ describe('#views.list', async () => {
|
||||||
expect(body.data[0].user.name).toBe(user.name);
|
expect(body.data[0].user.name).toBe(user.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return views for a document in read-only collection', async () => {
|
||||||
|
const { user, document, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
await View.increment({ documentId: document.id, userId: user.id });
|
||||||
|
|
||||||
|
const res = await server.post('/api/views.list', {
|
||||||
|
body: { token: user.getJwtToken(), documentId: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data[0].count).toBe(1);
|
||||||
|
expect(body.data[0].user.name).toBe(user.name);
|
||||||
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { document } = await seed();
|
const { document } = await seed();
|
||||||
const res = await server.post('/api/views.list', {
|
const res = await server.post('/api/views.list', {
|
||||||
|
@ -58,6 +82,27 @@ describe('#views.create', async () => {
|
||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow creating a view record for document in read-only collection', async () => {
|
||||||
|
const { user, document, collection } = await seed();
|
||||||
|
collection.private = true;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
await CollectionUser.create({
|
||||||
|
createdById: user.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
userId: user.id,
|
||||||
|
permission: 'read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/views.create', {
|
||||||
|
body: { token: user.getJwtToken(), documentId: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { document } = await seed();
|
const { document } = await seed();
|
||||||
const res = await server.post('/api/views.create', {
|
const res = await server.post('/api/views.create', {
|
||||||
|
|
|
@ -53,7 +53,9 @@ if (process.env.WEBSOCKETS_ENABLED === 'true') {
|
||||||
// allow the client to request to join rooms based on
|
// allow the client to request to join rooms based on
|
||||||
// new collections being created.
|
// new collections being created.
|
||||||
socket.on('join', async event => {
|
socket.on('join', async event => {
|
||||||
const collection = await Collection.findByPk(event.roomId);
|
const collection = await Collection.scope({
|
||||||
|
method: ['withMembership', user.id],
|
||||||
|
}).findByPk(event.roomId);
|
||||||
|
|
||||||
if (can(user, 'read', collection)) {
|
if (can(user, 'read', collection)) {
|
||||||
socket.join(`collection-${event.roomId}`);
|
socket.join(`collection-${event.roomId}`);
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('collections', 'maintainerApprovalRequired', {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
});
|
||||||
|
await queryInterface.changeColumn('collection_users', 'permission', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'read_write',
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('collection_users', ['permission']);
|
||||||
|
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('collections', 'maintainerApprovalRequired');
|
||||||
|
await queryInterface.changeColumn('collection_users', 'permission', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
|
await queryInterface.removeIndex('collection_users', ['permission']);
|
||||||
|
},
|
||||||
|
};
|
|
@ -21,6 +21,7 @@ const Collection = sequelize.define(
|
||||||
description: DataTypes.STRING,
|
description: DataTypes.STRING,
|
||||||
color: DataTypes.STRING,
|
color: DataTypes.STRING,
|
||||||
private: DataTypes.BOOLEAN,
|
private: DataTypes.BOOLEAN,
|
||||||
|
maintainerApprovalRequired: DataTypes.BOOLEAN,
|
||||||
type: {
|
type: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
validate: { isIn: [['atlas', 'journal']] },
|
validate: { isIn: [['atlas', 'journal']] },
|
||||||
|
@ -53,6 +54,11 @@ Collection.associate = models => {
|
||||||
foreignKey: 'collectionId',
|
foreignKey: 'collectionId',
|
||||||
onDelete: 'cascade',
|
onDelete: 'cascade',
|
||||||
});
|
});
|
||||||
|
Collection.hasMany(models.CollectionUser, {
|
||||||
|
as: 'memberships',
|
||||||
|
foreignKey: 'collectionId',
|
||||||
|
onDelete: 'cascade',
|
||||||
|
});
|
||||||
Collection.belongsToMany(models.User, {
|
Collection.belongsToMany(models.User, {
|
||||||
as: 'users',
|
as: 'users',
|
||||||
through: models.CollectionUser,
|
through: models.CollectionUser,
|
||||||
|
@ -65,20 +71,16 @@ Collection.associate = models => {
|
||||||
Collection.belongsTo(models.Team, {
|
Collection.belongsTo(models.Team, {
|
||||||
as: 'team',
|
as: 'team',
|
||||||
});
|
});
|
||||||
Collection.addScope(
|
Collection.addScope('withMembership', userId => ({
|
||||||
'defaultScope',
|
include: [
|
||||||
{
|
{
|
||||||
include: [
|
model: models.CollectionUser,
|
||||||
{
|
as: 'memberships',
|
||||||
model: models.User,
|
where: { userId },
|
||||||
as: 'users',
|
required: false,
|
||||||
through: 'collection_users',
|
},
|
||||||
paranoid: false,
|
],
|
||||||
},
|
}));
|
||||||
],
|
|
||||||
},
|
|
||||||
{ override: true }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Collection.addHook('afterDestroy', async (model: Collection) => {
|
Collection.addHook('afterDestroy', async (model: Collection) => {
|
||||||
|
|
|
@ -6,8 +6,9 @@ const CollectionUser = sequelize.define(
|
||||||
{
|
{
|
||||||
permission: {
|
permission: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: 'read_write',
|
||||||
validate: {
|
validate: {
|
||||||
isIn: [['read', 'read_write']],
|
isIn: [['read', 'read_write', 'maintainer']],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -154,25 +154,43 @@ Document.associate = models => {
|
||||||
Document.hasMany(models.View, {
|
Document.hasMany(models.View, {
|
||||||
as: 'views',
|
as: 'views',
|
||||||
});
|
});
|
||||||
Document.addScope(
|
Document.addScope('defaultScope', {
|
||||||
'defaultScope',
|
include: [
|
||||||
{
|
{ model: models.User, as: 'createdBy', paranoid: false },
|
||||||
include: [
|
{ model: models.User, as: 'updatedBy', paranoid: false },
|
||||||
{ model: models.Collection, as: 'collection' },
|
],
|
||||||
{ model: models.User, as: 'createdBy', paranoid: false },
|
where: {
|
||||||
{ model: models.User, as: 'updatedBy', paranoid: false },
|
publishedAt: {
|
||||||
],
|
[Op.ne]: null,
|
||||||
where: {
|
|
||||||
publishedAt: {
|
|
||||||
[Op.ne]: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ override: true }
|
});
|
||||||
);
|
Document.addScope('withCollection', userId => {
|
||||||
|
if (userId) {
|
||||||
|
return {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: models.Collection,
|
||||||
|
as: 'collection',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: models.CollectionUser,
|
||||||
|
as: 'memberships',
|
||||||
|
where: { userId },
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
include: [{ model: models.Collection, as: 'collection' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
Document.addScope('withUnpublished', {
|
Document.addScope('withUnpublished', {
|
||||||
include: [
|
include: [
|
||||||
{ model: models.Collection, as: 'collection' },
|
|
||||||
{ model: models.User, as: 'createdBy', paranoid: false },
|
{ model: models.User, as: 'createdBy', paranoid: false },
|
||||||
{ model: models.User, as: 'updatedBy', paranoid: false },
|
{ model: models.User, as: 'updatedBy', paranoid: false },
|
||||||
],
|
],
|
||||||
|
@ -189,8 +207,12 @@ Document.associate = models => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
Document.findByPk = async (id, options) => {
|
Document.findByPk = async function(id, options = {}) {
|
||||||
const scope = Document.scope('withUnpublished');
|
// allow default preloading of collection membership if `userId` is passed in find options
|
||||||
|
// almost every endpoint needs the collection membership to determine policy permissions.
|
||||||
|
const scope = this.scope('withUnpublished', {
|
||||||
|
method: ['withCollection', options.userId],
|
||||||
|
});
|
||||||
|
|
||||||
if (isUUID(id)) {
|
if (isUUID(id)) {
|
||||||
return scope.findOne({
|
return scope.findOne({
|
||||||
|
@ -350,14 +372,18 @@ Document.searchForUser = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
// Final query to get associated document data
|
// Final query to get associated document data
|
||||||
const documents = await Document.scope({
|
const documents = await Document.scope(
|
||||||
method: ['withViews', user.id],
|
{
|
||||||
}).findAll({
|
method: ['withViews', user.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: ['withCollection', user.id],
|
||||||
|
}
|
||||||
|
).findAll({
|
||||||
where: {
|
where: {
|
||||||
id: map(results, 'id'),
|
id: map(results, 'id'),
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{ model: Collection, as: 'collection' },
|
|
||||||
{ model: User, as: 'createdBy', paranoid: false },
|
{ model: User, as: 'createdBy', paranoid: false },
|
||||||
{ model: User, as: 'updatedBy', paranoid: false },
|
{ model: User, as: 'updatedBy', paranoid: false },
|
||||||
],
|
],
|
||||||
|
@ -450,7 +476,6 @@ Document.prototype.publish = async function(options) {
|
||||||
|
|
||||||
this.publishedAt = new Date();
|
this.publishedAt = new Date();
|
||||||
await this.save(options);
|
await this.save(options);
|
||||||
this.collection = collection;
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
|
@ -51,6 +51,11 @@ User.associate = models => {
|
||||||
});
|
});
|
||||||
User.hasMany(models.Document, { as: 'documents' });
|
User.hasMany(models.Document, { as: 'documents' });
|
||||||
User.hasMany(models.View, { as: 'views' });
|
User.hasMany(models.View, { as: 'views' });
|
||||||
|
User.belongsToMany(models.User, {
|
||||||
|
as: 'users',
|
||||||
|
through: 'collection_users',
|
||||||
|
onDelete: 'cascade',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
@ -61,7 +66,6 @@ User.prototype.collectionIds = async function(paranoid: boolean = true) {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
through: 'collection_users',
|
|
||||||
as: 'users',
|
as: 'users',
|
||||||
where: { id: this.id },
|
where: { id: this.id },
|
||||||
required: false,
|
required: false,
|
||||||
|
|
|
@ -228,13 +228,22 @@ export default function Api() {
|
||||||
</Arguments>
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
<Method method="collections.users" label="List collection members">
|
<Method
|
||||||
|
method="collections.memberships"
|
||||||
|
label="List collection members"
|
||||||
|
>
|
||||||
<Description>
|
<Description>
|
||||||
This method allows you to list users with access to a private
|
This method allows you to list a collections memberships. This is
|
||||||
collection.
|
both a collections maintainers, and user permissions for read and
|
||||||
|
write if the collection is private
|
||||||
</Description>
|
</Description>
|
||||||
<Arguments>
|
<Arguments pagination>
|
||||||
<Argument id="id" description="Collection ID" required />
|
<Argument id="id" description="Collection ID" required />
|
||||||
|
<Argument id="query" description="Filter results by user name" />
|
||||||
|
<Argument
|
||||||
|
id="permission"
|
||||||
|
description="Filter results by permission"
|
||||||
|
/>
|
||||||
</Arguments>
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
|
@ -570,7 +579,13 @@ export default function Api() {
|
||||||
label="Get pinned documents for a collection"
|
label="Get pinned documents for a collection"
|
||||||
>
|
>
|
||||||
<Description>Return pinned documents for a collection</Description>
|
<Description>Return pinned documents for a collection</Description>
|
||||||
<Arguments pagination />
|
<Arguments pagination>
|
||||||
|
<Argument
|
||||||
|
id="collectionId"
|
||||||
|
description="Collection ID"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
<Method
|
<Method
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import invariant from 'invariant';
|
||||||
import policy from './policy';
|
import policy from './policy';
|
||||||
import { map } from 'lodash';
|
|
||||||
import { Collection, User } from '../models';
|
import { Collection, User } from '../models';
|
||||||
import { AdminRequiredError } from '../errors';
|
import { AdminRequiredError } from '../errors';
|
||||||
|
|
||||||
|
@ -8,34 +8,56 @@ const { allow } = policy;
|
||||||
|
|
||||||
allow(User, 'create', Collection);
|
allow(User, 'create', Collection);
|
||||||
|
|
||||||
allow(
|
allow(User, ['read', 'export'], Collection, (user, collection) => {
|
||||||
User,
|
|
||||||
['read', 'publish', 'update', 'export'],
|
|
||||||
Collection,
|
|
||||||
(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 (!collection || user.teamId !== collection.teamId) return false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
collection.private &&
|
collection.private &&
|
||||||
!map(collection.users, u => u.id).includes(user.id)
|
(!collection.memberships || !collection.memberships.length)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, ['publish', 'update'], Collection, (user, collection) => {
|
||||||
|
if (!collection || user.teamId !== collection.teamId) return false;
|
||||||
|
|
||||||
|
if (collection.private) {
|
||||||
|
invariant(
|
||||||
|
collection.memberships,
|
||||||
|
'membership should be preloaded, did you forget withMembership scope?'
|
||||||
|
);
|
||||||
|
if (!collection.memberships.length) return false;
|
||||||
|
|
||||||
|
return ['read_write', 'maintainer'].includes(
|
||||||
|
collection.memberships[0].permission
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, 'delete', Collection, (user, collection) => {
|
||||||
|
if (!collection || user.teamId !== collection.teamId) return false;
|
||||||
|
|
||||||
|
if (collection.private) {
|
||||||
|
invariant(
|
||||||
|
collection.memberships,
|
||||||
|
'membership should be preloaded, did you forget withMembership scope?'
|
||||||
|
);
|
||||||
|
if (!collection.memberships.length) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!['read_write', 'maintainer'].includes(
|
||||||
|
collection.memberships[0].permission
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (user.isAdmin) return true;
|
if (user.isAdmin) return true;
|
||||||
if (user.id === collection.creatorId) return true;
|
if (user.id === collection.creatorId) return true;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import invariant from 'invariant';
|
||||||
import policy from './policy';
|
import policy from './policy';
|
||||||
import { Document, Revision, User } from '../models';
|
import { Document, Revision, User } from '../models';
|
||||||
|
|
||||||
|
@ -6,27 +7,83 @@ const { allow, cannot } = policy;
|
||||||
|
|
||||||
allow(User, 'create', Document);
|
allow(User, 'create', Document);
|
||||||
|
|
||||||
allow(User, ['read', 'delete'], Document, (user, document) => {
|
allow(User, ['read', 'download'], Document, (user, document) => {
|
||||||
if (document.collection) {
|
// existance of collection option is not required here to account for share tokens
|
||||||
if (cannot(user, 'read', document.collection)) return false;
|
if (document.collection && cannot(user, 'read', document.collection)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.teamId === document.teamId;
|
return user.teamId === document.teamId;
|
||||||
});
|
});
|
||||||
|
|
||||||
allow(User, ['update', 'move', 'share'], Document, (user, document) => {
|
allow(User, ['share'], Document, (user, document) => {
|
||||||
if (document.collection) {
|
if (document.archivedAt) return false;
|
||||||
if (cannot(user, 'read', document.collection)) return false;
|
|
||||||
|
// existance of collection option is not required here to account for share tokens
|
||||||
|
if (document.collection && cannot(user, 'read', document.collection)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, ['star', 'unstar'], Document, (user, document) => {
|
||||||
|
if (document.archivedAt) return false;
|
||||||
|
if (!document.publishedAt) return false;
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
document.collection,
|
||||||
|
'collection is missing, did you forget to include in the query scope?'
|
||||||
|
);
|
||||||
|
if (cannot(user, 'read', document.collection)) return false;
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, 'update', Document, (user, document) => {
|
||||||
|
invariant(
|
||||||
|
document.collection,
|
||||||
|
'collection is missing, did you forget to include in the query scope?'
|
||||||
|
);
|
||||||
|
if (cannot(user, 'update', document.collection)) return false;
|
||||||
|
if (document.archivedAt) return false;
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, ['move', 'pin', 'unpin'], Document, (user, document) => {
|
||||||
|
invariant(
|
||||||
|
document.collection,
|
||||||
|
'collection is missing, did you forget to include in the query scope?'
|
||||||
|
);
|
||||||
|
if (cannot(user, 'update', document.collection)) return false;
|
||||||
|
if (document.archivedAt) return false;
|
||||||
|
if (!document.publishedAt) return false;
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, 'delete', Document, (user, document) => {
|
||||||
|
// unpublished drafts can always be deleted
|
||||||
|
if (!document.publishedAt && user.teamId === document.teamId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow deleting document without a collection
|
||||||
|
if (document.collection && cannot(user, 'update', document.collection))
|
||||||
|
return false;
|
||||||
if (document.archivedAt) return false;
|
if (document.archivedAt) return false;
|
||||||
|
|
||||||
return user.teamId === document.teamId;
|
return user.teamId === document.teamId;
|
||||||
});
|
});
|
||||||
|
|
||||||
allow(User, 'archive', Document, (user, document) => {
|
allow(User, 'archive', Document, (user, document) => {
|
||||||
if (document.collection) {
|
invariant(
|
||||||
if (cannot(user, 'read', document.collection)) return false;
|
document.collection,
|
||||||
}
|
'collection is missing, did you forget to include in the query scope?'
|
||||||
|
);
|
||||||
|
if (cannot(user, 'update', document.collection)) return false;
|
||||||
|
|
||||||
if (!document.publishedAt) return false;
|
if (!document.publishedAt) return false;
|
||||||
if (document.archivedAt) return false;
|
if (document.archivedAt) return false;
|
||||||
|
|
||||||
|
@ -34,9 +91,12 @@ allow(User, 'archive', Document, (user, document) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
allow(User, 'unarchive', Document, (user, document) => {
|
allow(User, 'unarchive', Document, (user, document) => {
|
||||||
if (document.collection) {
|
invariant(
|
||||||
if (cannot(user, 'read', document.collection)) return false;
|
document.collection,
|
||||||
}
|
'collection is missing, did you forget to include in the query scope?'
|
||||||
|
);
|
||||||
|
if (cannot(user, 'update', document.collection)) return false;
|
||||||
|
|
||||||
if (!document.archivedAt) return false;
|
if (!document.archivedAt) return false;
|
||||||
|
|
||||||
return user.teamId === document.teamId;
|
return user.teamId === document.teamId;
|
||||||
|
|
|
@ -9,6 +9,7 @@ import presentApiKey from './apiKey';
|
||||||
import presentShare from './share';
|
import presentShare from './share';
|
||||||
import presentTeam from './team';
|
import presentTeam from './team';
|
||||||
import presentIntegration from './integration';
|
import presentIntegration from './integration';
|
||||||
|
import presentMembership from './membership';
|
||||||
import presentNotificationSetting from './notificationSetting';
|
import presentNotificationSetting from './notificationSetting';
|
||||||
import presentSlackAttachment from './slackAttachment';
|
import presentSlackAttachment from './slackAttachment';
|
||||||
import presentPolicies from './policy';
|
import presentPolicies from './policy';
|
||||||
|
@ -24,6 +25,7 @@ export {
|
||||||
presentShare,
|
presentShare,
|
||||||
presentTeam,
|
presentTeam,
|
||||||
presentIntegration,
|
presentIntegration,
|
||||||
|
presentMembership,
|
||||||
presentNotificationSetting,
|
presentNotificationSetting,
|
||||||
presentSlackAttachment,
|
presentSlackAttachment,
|
||||||
presentPolicies,
|
presentPolicies,
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
// @flow
|
||||||
|
import { CollectionUser } from '../models';
|
||||||
|
|
||||||
|
type Membership = {
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
collectionId: string,
|
||||||
|
permission: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (membership: CollectionUser): Membership => {
|
||||||
|
return {
|
||||||
|
id: `${membership.userId}-${membership.collectionId}`,
|
||||||
|
userId: membership.userId,
|
||||||
|
collectionId: membership.collectionId,
|
||||||
|
permission: membership.permission,
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { Document, Team } from '../models';
|
import { Document, Collection, Team } from '../models';
|
||||||
|
|
||||||
type Action = {
|
type Action = {
|
||||||
type: string,
|
type: string,
|
||||||
|
@ -10,6 +10,7 @@ type Action = {
|
||||||
|
|
||||||
export default function present(
|
export default function present(
|
||||||
document: Document,
|
document: Document,
|
||||||
|
collection: Collection,
|
||||||
team: Team,
|
team: Team,
|
||||||
context?: string,
|
context?: string,
|
||||||
actions?: Action[]
|
actions?: Action[]
|
||||||
|
@ -21,10 +22,10 @@ export default function present(
|
||||||
: document.getSummary();
|
: document.getSummary();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
color: document.collection.color,
|
color: collection.color,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
title_link: `${team.url}${document.url}`,
|
title_link: `${team.url}${document.url}`,
|
||||||
footer: document.collection.name,
|
footer: collection.name,
|
||||||
callback_id: document.id,
|
callback_id: document.id,
|
||||||
text,
|
text,
|
||||||
ts: document.getTimestamp(),
|
ts: document.getTimestamp(),
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
import type { Event } from '../events';
|
import type { Event } from '../events';
|
||||||
import { Document, Collection } from '../models';
|
import { Document, Collection } from '../models';
|
||||||
import { presentDocument, presentCollection } from '../presenters';
|
|
||||||
import { socketio } from '../';
|
import { socketio } from '../';
|
||||||
|
|
||||||
export default class Websockets {
|
export default class Websockets {
|
||||||
|
@ -12,34 +11,97 @@ export default class Websockets {
|
||||||
case 'documents.publish':
|
case 'documents.publish':
|
||||||
case 'documents.restore':
|
case 'documents.restore':
|
||||||
case 'documents.archive':
|
case 'documents.archive':
|
||||||
case 'documents.unarchive':
|
case 'documents.unarchive': {
|
||||||
case 'documents.pin':
|
|
||||||
case 'documents.unpin':
|
|
||||||
case 'documents.update':
|
|
||||||
case 'documents.delete': {
|
|
||||||
const document = await Document.findByPk(event.documentId, {
|
const document = await Document.findByPk(event.documentId, {
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
const documents = [await presentDocument(document)];
|
|
||||||
const collections = [await presentCollection(document.collection)];
|
|
||||||
|
|
||||||
return socketio
|
return socketio
|
||||||
.to(`collection-${document.collectionId}`)
|
.to(`collection-${document.collectionId}`)
|
||||||
.emit('entities', {
|
.emit('entities', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
documents,
|
documentIds: [
|
||||||
collections,
|
{
|
||||||
|
id: document.id,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
collectionIds: [
|
||||||
|
{
|
||||||
|
id: document.collectionId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'documents.delete': {
|
||||||
|
const document = await Document.findByPk(event.documentId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document.publishedAt) {
|
||||||
|
return socketio.to(`user-${document.createdById}`).emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
documentIds: [
|
||||||
|
{
|
||||||
|
id: document.id,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return socketio
|
||||||
|
.to(`collection-${document.collectionId}`)
|
||||||
|
.emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
documentIds: [
|
||||||
|
{
|
||||||
|
id: document.id,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
collectionIds: [
|
||||||
|
{
|
||||||
|
id: document.collectionId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'documents.pin':
|
||||||
|
case 'documents.unpin':
|
||||||
|
case 'documents.update': {
|
||||||
|
const document = await Document.findByPk(event.documentId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return socketio
|
||||||
|
.to(`collection-${document.collectionId}`)
|
||||||
|
.emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
documentIds: [
|
||||||
|
{
|
||||||
|
id: document.id,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'documents.create': {
|
case 'documents.create': {
|
||||||
const document = await Document.findByPk(event.documentId);
|
const document = await Document.findByPk(event.documentId);
|
||||||
const documents = [await presentDocument(document)];
|
|
||||||
const collections = [await presentCollection(document.collection)];
|
|
||||||
|
|
||||||
return socketio.to(`user-${event.actorId}`).emit('entities', {
|
return socketio.to(`user-${event.actorId}`).emit('entities', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
documents,
|
documentIds: [
|
||||||
collections,
|
{
|
||||||
|
id: document.id,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
collectionIds: [
|
||||||
|
{
|
||||||
|
id: document.collectionId,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'documents.star':
|
case 'documents.star':
|
||||||
|
@ -55,24 +117,21 @@ export default class Websockets {
|
||||||
},
|
},
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
const collections = await Collection.findAll({
|
documents.forEach(document => {
|
||||||
where: {
|
|
||||||
id: event.data.collectionIds,
|
|
||||||
},
|
|
||||||
paranoid: false,
|
|
||||||
});
|
|
||||||
documents.forEach(async document => {
|
|
||||||
const documents = [await presentDocument(document)];
|
|
||||||
socketio.to(`collection-${document.collectionId}`).emit('entities', {
|
socketio.to(`collection-${document.collectionId}`).emit('entities', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
documents,
|
documentIds: [
|
||||||
|
{
|
||||||
|
id: document.id,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
collections.forEach(async collection => {
|
event.data.collectionIds.forEach(collectionId => {
|
||||||
const collections = [await presentCollection(collection)];
|
socketio.to(`collection-${collectionId}`).emit('entities', {
|
||||||
socketio.to(`collection-${collection.id}`).emit('entities', {
|
|
||||||
event: event.name,
|
event: event.name,
|
||||||
collections,
|
collectionIds: [{ id: collectionId }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -81,7 +140,6 @@ export default class Websockets {
|
||||||
const collection = await Collection.findByPk(event.collectionId, {
|
const collection = await Collection.findByPk(event.collectionId, {
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
const collections = [await presentCollection(collection)];
|
|
||||||
|
|
||||||
socketio
|
socketio
|
||||||
.to(
|
.to(
|
||||||
|
@ -91,7 +149,12 @@ export default class Websockets {
|
||||||
)
|
)
|
||||||
.emit('entities', {
|
.emit('entities', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
collections,
|
collectionIds: [
|
||||||
|
{
|
||||||
|
id: collection.id,
|
||||||
|
updatedAt: collection.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return socketio
|
return socketio
|
||||||
.to(
|
.to(
|
||||||
|
@ -109,24 +172,53 @@ export default class Websockets {
|
||||||
const collection = await Collection.findByPk(event.collectionId, {
|
const collection = await Collection.findByPk(event.collectionId, {
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
const collections = [await presentCollection(collection)];
|
|
||||||
|
|
||||||
return socketio.to(`collection-${collection.id}`).emit('entities', {
|
return socketio.to(`team-${collection.teamId}`).emit('entities', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
collections,
|
collectionIds: [
|
||||||
|
{
|
||||||
|
id: collection.id,
|
||||||
|
updatedAt: collection.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'collections.add_user':
|
case 'collections.add_user': {
|
||||||
|
// the user being added isn't yet in the websocket channel for the collection
|
||||||
|
// so they need to be notified separately
|
||||||
|
socketio.to(`user-${event.userId}`).emit(event.name, {
|
||||||
|
event: event.name,
|
||||||
|
userId: event.userId,
|
||||||
|
collectionId: event.collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// let everyone with access to the collection know a user was added
|
||||||
|
socketio.to(`collection-${event.collectionId}`).emit(event.name, {
|
||||||
|
event: event.name,
|
||||||
|
userId: event.userId,
|
||||||
|
collectionId: event.collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// tell any user clients to connect to the websocket channel for the collection
|
||||||
return socketio.to(`user-${event.userId}`).emit('join', {
|
return socketio.to(`user-${event.userId}`).emit('join', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
roomId: event.collectionId,
|
roomId: event.collectionId,
|
||||||
});
|
});
|
||||||
case 'collections.remove_user':
|
}
|
||||||
|
case 'collections.remove_user': {
|
||||||
|
// let everyone with access to the collection know a user was removed
|
||||||
|
socketio.to(`collection-${event.collectionId}`).emit(event.name, {
|
||||||
|
event: event.name,
|
||||||
|
userId: event.userId,
|
||||||
|
collectionId: event.collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// tell any user clients to disconnect from the websocket channel for the collection
|
||||||
return socketio.to(`user-${event.userId}`).emit('leave', {
|
return socketio.to(`user-${event.userId}`).emit('leave', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
roomId: event.collectionId,
|
roomId: event.collectionId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ type Props = {
|
||||||
|
|
||||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||||
const collection = collections.get(document.collectionId);
|
const collection = collections.get(document.collectionId);
|
||||||
if (!collection) return null;
|
if (!collection) return <div />;
|
||||||
|
|
||||||
const path = collection.pathToDocument(document).slice(0, -1);
|
const path = collection.pathToDocument(document).slice(0, -1);
|
||||||
|
|
||||||
|
|
Reference in New Issue