5
Makefile
5
Makefile
@@ -9,8 +9,11 @@ build:
|
|||||||
test:
|
test:
|
||||||
docker-compose run --rm outline yarn test
|
docker-compose run --rm outline yarn test
|
||||||
|
|
||||||
|
watch:
|
||||||
|
docker-compose run --rm outline yarn test:watch
|
||||||
|
|
||||||
destroy:
|
destroy:
|
||||||
docker-compose stop
|
docker-compose stop
|
||||||
docker-compose rm -f
|
docker-compose rm -f
|
||||||
|
|
||||||
.PHONY: up build destroy # let's go to reserve rules names
|
.PHONY: up build destroy test watch # let's go to reserve rules names
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Outline is still built and maintained by a small team – we'd love your help to
|
|||||||
|
|
||||||
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
|
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
|
||||||
|
|
||||||
If you're looking for ways to get started, here's a list of ways to help us improve Outline:
|
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||||
|
|
||||||
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||||
* Performance improvements, both on server and frontend
|
* Performance improvements, both on server and frontend
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Provider } from 'mobx-react';
|
|||||||
import stores from 'stores';
|
import stores from 'stores';
|
||||||
import ApiKeysStore from 'stores/ApiKeysStore';
|
import ApiKeysStore from 'stores/ApiKeysStore';
|
||||||
import UsersStore from 'stores/UsersStore';
|
import UsersStore from 'stores/UsersStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
import IntegrationsStore from 'stores/IntegrationsStore';
|
import IntegrationsStore from 'stores/IntegrationsStore';
|
||||||
import CacheStore from 'stores/CacheStore';
|
import CacheStore from 'stores/CacheStore';
|
||||||
@@ -27,10 +26,6 @@ const Auth = ({ children }: Props) => {
|
|||||||
integrations: new IntegrationsStore(),
|
integrations: new IntegrationsStore(),
|
||||||
apiKeys: new ApiKeysStore(),
|
apiKeys: new ApiKeysStore(),
|
||||||
users: new UsersStore(),
|
users: new UsersStore(),
|
||||||
documents: new DocumentsStore({
|
|
||||||
ui: stores.ui,
|
|
||||||
cache,
|
|
||||||
}),
|
|
||||||
collections: new CollectionsStore({
|
collections: new CollectionsStore({
|
||||||
ui: stores.ui,
|
ui: stores.ui,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ import { observer } from 'mobx-react';
|
|||||||
import { color } from 'shared/styles/constants';
|
import { color } from 'shared/styles/constants';
|
||||||
import placeholder from './placeholder.png';
|
import placeholder from './placeholder.png';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
src: string,
|
||||||
|
size: number,
|
||||||
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Avatar extends React.Component<*> {
|
class Avatar extends React.Component<Props> {
|
||||||
@observable error: boolean;
|
@observable error: boolean;
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
size: 24,
|
||||||
|
};
|
||||||
|
|
||||||
handleError = () => {
|
handleError = () => {
|
||||||
this.error = true;
|
this.error = true;
|
||||||
};
|
};
|
||||||
@@ -17,7 +26,7 @@ class Avatar extends React.Component<*> {
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<CircleImg
|
<CircleImg
|
||||||
{...this.props}
|
size={this.props.size}
|
||||||
onError={this.handleError}
|
onError={this.handleError}
|
||||||
src={this.error ? placeholder : this.props.src}
|
src={this.error ? placeholder : this.props.src}
|
||||||
/>
|
/>
|
||||||
@@ -26,8 +35,8 @@ class Avatar extends React.Component<*> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CircleImg = styled.img`
|
const CircleImg = styled.img`
|
||||||
width: 24px;
|
width: ${props => props.size}px;
|
||||||
height: 24px;
|
height: ${props => props.size}px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid ${color.white};
|
border: 2px solid ${color.white};
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { color } from 'shared/styles/constants';
|
import { color } from 'shared/styles/constants';
|
||||||
import { darken, lighten } from 'polished';
|
import { darken } from 'polished';
|
||||||
|
|
||||||
const RealButton = styled.button`
|
const RealButton = styled.button`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -40,11 +40,14 @@ const RealButton = styled.button`
|
|||||||
${props =>
|
${props =>
|
||||||
props.light &&
|
props.light &&
|
||||||
`
|
`
|
||||||
color: ${color.text};
|
color: ${color.slate};
|
||||||
background: ${lighten(0.08, color.slateLight)};
|
background: transparent;
|
||||||
|
border: 1px solid ${color.slate};
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${color.slateLight};
|
background: transparent;
|
||||||
|
color: ${color.slateDark};
|
||||||
|
border: 1px solid ${color.slateDark};
|
||||||
}
|
}
|
||||||
`} ${props =>
|
`} ${props =>
|
||||||
props.neutral &&
|
props.neutral &&
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import copy from 'copy-to-clipboard';
|
|||||||
type Props = {
|
type Props = {
|
||||||
text: string,
|
text: string,
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
onClick?: () => void,
|
onClick?: () => *,
|
||||||
onCopy: () => void,
|
onCopy: () => *,
|
||||||
};
|
};
|
||||||
|
|
||||||
class CopyToClipboard extends React.PureComponent<Props> {
|
class CopyToClipboard extends React.PureComponent<Props> {
|
||||||
|
|||||||
@@ -112,7 +112,14 @@ class DocumentPreview extends React.Component<Props> {
|
|||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLink to={document.url} innerRef={innerRef} {...rest}>
|
<DocumentLink
|
||||||
|
to={{
|
||||||
|
pathname: document.url,
|
||||||
|
state: { title: document.title },
|
||||||
|
}}
|
||||||
|
innerRef={innerRef}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
<Heading>
|
<Heading>
|
||||||
<Highlight text={document.title} highlight={highlight} />
|
<Highlight text={document.title} highlight={highlight} />
|
||||||
{document.publishedAt && (
|
{document.publishedAt && (
|
||||||
|
|||||||
57
app/components/List/Item.js
Normal file
57
app/components/List/Item.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { color, fontSize } from 'shared/styles/constants';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
image?: React.Node,
|
||||||
|
title: string,
|
||||||
|
subtitle: React.Node,
|
||||||
|
actions?: React.Node,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
{image && <Image>{image}</Image>}
|
||||||
|
<Content>
|
||||||
|
<Heading>{title}</Heading>
|
||||||
|
<Subtitle>{subtitle}</Subtitle>
|
||||||
|
</Content>
|
||||||
|
{actions && <Actions>{actions}</Actions>}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wrapper = styled.li`
|
||||||
|
display: flex;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid ${color.smokeDark};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Image = styled.div`
|
||||||
|
padding: 0 8px 0 0;
|
||||||
|
max-height: 40px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Heading = styled.h2`
|
||||||
|
font-size: ${fontSize.medium};
|
||||||
|
margin: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Content = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
margin: 0;
|
||||||
|
font-size: ${fontSize.small};
|
||||||
|
color: ${color.slate};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Actions = styled.div`
|
||||||
|
align-self: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default ListItem;
|
||||||
10
app/components/List/List.js
Normal file
10
app/components/List/List.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// @flow
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const List = styled.ol`
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default List;
|
||||||
3
app/components/List/index.js
Normal file
3
app/components/List/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// @flow
|
||||||
|
import List from './List';
|
||||||
|
export default List;
|
||||||
@@ -7,6 +7,7 @@ import CollectionNew from 'scenes/CollectionNew';
|
|||||||
import CollectionEdit from 'scenes/CollectionEdit';
|
import CollectionEdit from 'scenes/CollectionEdit';
|
||||||
import CollectionDelete from 'scenes/CollectionDelete';
|
import CollectionDelete from 'scenes/CollectionDelete';
|
||||||
import DocumentDelete from 'scenes/DocumentDelete';
|
import DocumentDelete from 'scenes/DocumentDelete';
|
||||||
|
import DocumentShare from 'scenes/DocumentShare';
|
||||||
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -44,6 +45,9 @@ class Modals extends React.Component<Props> {
|
|||||||
<Modal name="collection-delete" title="Delete collection">
|
<Modal name="collection-delete" title="Delete collection">
|
||||||
<CollectionDelete onSubmit={this.handleClose} />
|
<CollectionDelete onSubmit={this.handleClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal name="document-share" title="Share document">
|
||||||
|
<DocumentShare onSubmit={this.handleClose} />
|
||||||
|
</Modal>
|
||||||
<Modal name="document-delete" title="Delete document">
|
<Modal name="document-delete" title="Delete document">
|
||||||
<DocumentDelete onSubmit={this.handleClose} />
|
<DocumentDelete onSubmit={this.handleClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ class MainSidebar extends React.Component<Props> {
|
|||||||
this.props.ui.setActiveModal('collection-new');
|
this.props.ui.setActiveModal('collection-new');
|
||||||
};
|
};
|
||||||
|
|
||||||
handleEditCollection = () => {
|
|
||||||
this.props.ui.setActiveModal('collection-edit');
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { auth, documents } = this.props;
|
const { auth, documents } = this.props;
|
||||||
const { user, team } = auth;
|
const { user, team } = auth;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import { ProfileIcon, SettingsIcon, CodeIcon, UserIcon } from 'outline-icons';
|
import {
|
||||||
|
ProfileIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
CodeIcon,
|
||||||
|
UserIcon,
|
||||||
|
LinkIcon,
|
||||||
|
} from 'outline-icons';
|
||||||
|
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import Sidebar, { Section } from './Sidebar';
|
import Sidebar, { Section } from './Sidebar';
|
||||||
@@ -48,8 +54,11 @@ class SettingsSidebar extends React.Component<Props> {
|
|||||||
</Section>
|
</Section>
|
||||||
<Section>
|
<Section>
|
||||||
<Header>Team</Header>
|
<Header>Team</Header>
|
||||||
<SidebarLink to="/settings/users" icon={<UserIcon />}>
|
<SidebarLink to="/settings/members" icon={<UserIcon />}>
|
||||||
Users
|
Members
|
||||||
|
</SidebarLink>
|
||||||
|
<SidebarLink to="/settings/shares" icon={<LinkIcon />}>
|
||||||
|
Share Links
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/settings/integrations/slack"
|
to="/settings/integrations/slack"
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ class DocumentLink extends React.Component<Props> {
|
|||||||
activeClassName="activeDropZone"
|
activeClassName="activeDropZone"
|
||||||
>
|
>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to={document.url}
|
to={{
|
||||||
|
pathname: document.url,
|
||||||
|
state: { title: document.title },
|
||||||
|
}}
|
||||||
expand={showChildren}
|
expand={showChildren}
|
||||||
expandedContent={
|
expandedContent={
|
||||||
document.children.length ? (
|
document.children.length ? (
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const StyledNavLink = styled(NavLink)`
|
|||||||
const StyledDiv = StyledNavLink.withComponent('div');
|
const StyledDiv = StyledNavLink.withComponent('div');
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
to?: string,
|
to?: string | Object,
|
||||||
onClick?: (SyntheticEvent<*>) => *,
|
onClick?: (SyntheticEvent<*>) => *,
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
icon?: React.Node,
|
icon?: React.Node,
|
||||||
|
|||||||
11
app/index.js
11
app/index.js
@@ -21,8 +21,9 @@ import Collection from 'scenes/Collection';
|
|||||||
import Document from 'scenes/Document';
|
import Document from 'scenes/Document';
|
||||||
import Search from 'scenes/Search';
|
import Search from 'scenes/Search';
|
||||||
import Settings from 'scenes/Settings';
|
import Settings from 'scenes/Settings';
|
||||||
import Users from 'scenes/Settings/Users';
|
import Members from 'scenes/Settings/Members';
|
||||||
import Slack from 'scenes/Settings/Slack';
|
import Slack from 'scenes/Settings/Slack';
|
||||||
|
import Shares from 'scenes/Settings/Shares';
|
||||||
import Tokens from 'scenes/Settings/Tokens';
|
import Tokens from 'scenes/Settings/Tokens';
|
||||||
import SlackAuth from 'scenes/SlackAuth';
|
import SlackAuth from 'scenes/SlackAuth';
|
||||||
import ErrorAuth from 'scenes/ErrorAuth';
|
import ErrorAuth from 'scenes/ErrorAuth';
|
||||||
@@ -68,6 +69,7 @@ if (element) {
|
|||||||
/>
|
/>
|
||||||
<Route exact path="/auth/slack/post" component={SlackAuth} />
|
<Route exact path="/auth/slack/post" component={SlackAuth} />
|
||||||
<Route exact path="/auth/error" component={ErrorAuth} />
|
<Route exact path="/auth/error" component={ErrorAuth} />
|
||||||
|
<Route exact path="/share/:shareId" component={Document} />
|
||||||
<Auth>
|
<Auth>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -75,7 +77,12 @@ if (element) {
|
|||||||
<Route exact path="/starred" component={Starred} />
|
<Route exact path="/starred" component={Starred} />
|
||||||
<Route exact path="/drafts" component={Drafts} />
|
<Route exact path="/drafts" component={Drafts} />
|
||||||
<Route exact path="/settings" component={Settings} />
|
<Route exact path="/settings" component={Settings} />
|
||||||
<Route exact path="/settings/users" component={Users} />
|
<Route
|
||||||
|
exact
|
||||||
|
path="/settings/members"
|
||||||
|
component={Members}
|
||||||
|
/>
|
||||||
|
<Route exact path="/settings/shares" component={Shares} />
|
||||||
<Route exact path="/settings/tokens" component={Tokens} />
|
<Route exact path="/settings/tokens" component={Tokens} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ class DocumentMenu extends React.Component<Props> {
|
|||||||
this.props.document.download();
|
this.props.document.download();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleShareLink = async (ev: SyntheticEvent<*>) => {
|
||||||
|
const { document } = this.props;
|
||||||
|
if (!document.shareUrl) await document.share();
|
||||||
|
|
||||||
|
this.props.ui.setActiveModal('document-share', { document });
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { document, label, className, showPrint } = this.props;
|
const { document, label, className, showPrint } = this.props;
|
||||||
const isDraft = !document.publishedAt;
|
const isDraft = !document.publishedAt;
|
||||||
@@ -80,6 +87,12 @@ class DocumentMenu extends React.Component<Props> {
|
|||||||
Star
|
Star
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={this.handleShareLink}
|
||||||
|
title="Create a public share link"
|
||||||
|
>
|
||||||
|
Share link
|
||||||
|
</DropdownMenuItem>
|
||||||
<hr />
|
<hr />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={this.handleNewChild}
|
onClick={this.handleNewChild}
|
||||||
|
|||||||
54
app/menus/ShareMenu.js
Normal file
54
app/menus/ShareMenu.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { inject } from 'mobx-react';
|
||||||
|
import { MoreIcon } from 'outline-icons';
|
||||||
|
|
||||||
|
import { Share } from 'types';
|
||||||
|
import CopyToClipboard from 'components/CopyToClipboard';
|
||||||
|
import SharesStore from 'stores/SharesStore';
|
||||||
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label?: React.Node,
|
||||||
|
onOpen?: () => *,
|
||||||
|
onClose?: () => *,
|
||||||
|
history: Object,
|
||||||
|
shares: SharesStore,
|
||||||
|
share: Share,
|
||||||
|
};
|
||||||
|
|
||||||
|
class ShareMenu extends React.Component<Props> {
|
||||||
|
onGoToDocument = (ev: SyntheticEvent<*>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.props.history.push(this.props.share.documentUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
onRevoke = (ev: SyntheticEvent<*>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.props.shares.revoke(this.props.share);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { share, label, onOpen, onClose } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
label={label || <MoreIcon />}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<CopyToClipboard text={share.url} onCopy={onClose}>
|
||||||
|
<DropdownMenuItem>Copy link</DropdownMenuItem>
|
||||||
|
</CopyToClipboard>
|
||||||
|
<DropdownMenuItem onClick={this.onGoToDocument}>
|
||||||
|
Go to document
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<hr />
|
||||||
|
<DropdownMenuItem onClick={this.onRevoke}>Revoke link</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(inject('shares')(ShareMenu));
|
||||||
@@ -61,29 +61,27 @@ class UserMenu extends React.Component<Props> {
|
|||||||
const { user } = this.props;
|
const { user } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<DropdownMenu label={<MoreIcon />}>
|
||||||
<DropdownMenu label={<MoreIcon />}>
|
{!user.isSuspended &&
|
||||||
{!user.isSuspended &&
|
(user.isAdmin ? (
|
||||||
(user.isAdmin ? (
|
<DropdownMenuItem onClick={this.handleDemote}>
|
||||||
<DropdownMenuItem onClick={this.handleDemote}>
|
Make {user.name} a member…
|
||||||
Make {user.name} a member…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem onClick={this.handlePromote}>
|
|
||||||
Make {user.name} an admin…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
{user.isSuspended ? (
|
|
||||||
<DropdownMenuItem onClick={this.handleActivate}>
|
|
||||||
Activate account
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem onClick={this.handleSuspend}>
|
<DropdownMenuItem onClick={this.handlePromote}>
|
||||||
Suspend account…
|
Make {user.name} an admin…
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
))}
|
||||||
</DropdownMenu>
|
{user.isSuspended ? (
|
||||||
</span>
|
<DropdownMenuItem onClick={this.handleActivate}>
|
||||||
|
Activate account
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={this.handleSuspend}>
|
||||||
|
Suspend account…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,6 @@ class Document extends BaseModel {
|
|||||||
id: string;
|
id: string;
|
||||||
team: string;
|
team: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
private: boolean = false;
|
|
||||||
starred: boolean = false;
|
starred: boolean = false;
|
||||||
pinned: boolean = false;
|
pinned: boolean = false;
|
||||||
text: string = '';
|
text: string = '';
|
||||||
@@ -40,11 +39,10 @@ class Document extends BaseModel {
|
|||||||
parentDocument: ?string;
|
parentDocument: ?string;
|
||||||
publishedAt: ?string;
|
publishedAt: ?string;
|
||||||
url: string;
|
url: string;
|
||||||
|
shareUrl: ?string;
|
||||||
views: number;
|
views: number;
|
||||||
revision: number;
|
revision: number;
|
||||||
|
|
||||||
data: Object;
|
|
||||||
|
|
||||||
/* Computed */
|
/* Computed */
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
@@ -101,6 +99,18 @@ class Document extends BaseModel {
|
|||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
|
|
||||||
|
@action
|
||||||
|
share = async () => {
|
||||||
|
try {
|
||||||
|
const res = await client.post('/shares.create', { documentId: this.id });
|
||||||
|
invariant(res && res.data, 'Document API response should be available');
|
||||||
|
|
||||||
|
this.shareUrl = res.data.url;
|
||||||
|
} catch (e) {
|
||||||
|
this.errors.add('Document failed to share');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
pin = async () => {
|
pin = async () => {
|
||||||
this.pinned = true;
|
this.pinned = true;
|
||||||
@@ -277,7 +287,6 @@ class Document extends BaseModel {
|
|||||||
data.emoji = emoji;
|
data.emoji = emoji;
|
||||||
}
|
}
|
||||||
if (dirty) this.hasPendingChanges = true;
|
if (dirty) this.hasPendingChanges = true;
|
||||||
this.data = data;
|
|
||||||
extendObservable(this, data);
|
extendObservable(this, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import get from 'lodash/get';
|
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import breakpoint from 'styled-components-breakpoint';
|
import breakpoint from 'styled-components-breakpoint';
|
||||||
@@ -25,13 +24,14 @@ import Document from 'models/Document';
|
|||||||
import Actions from './components/Actions';
|
import Actions from './components/Actions';
|
||||||
import DocumentMove from './components/DocumentMove';
|
import DocumentMove from './components/DocumentMove';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
|
import AuthStore from 'stores/AuthStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
|
||||||
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
||||||
import LoadingIndicator from 'components/LoadingIndicator';
|
import LoadingIndicator from 'components/LoadingIndicator';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import Search from 'scenes/Search';
|
import Search from 'scenes/Search';
|
||||||
|
import Error404 from 'scenes/Error404';
|
||||||
|
|
||||||
const AUTOSAVE_INTERVAL = 3000;
|
const AUTOSAVE_INTERVAL = 3000;
|
||||||
const DISCARD_CHANGES = `
|
const DISCARD_CHANGES = `
|
||||||
@@ -44,8 +44,8 @@ type Props = {
|
|||||||
history: Object,
|
history: Object,
|
||||||
location: Location,
|
location: Location,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
collections: CollectionsStore,
|
|
||||||
newDocument?: boolean,
|
newDocument?: boolean,
|
||||||
|
auth: AuthStore,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
|
|
||||||
@observable editorComponent;
|
@observable editorComponent;
|
||||||
@observable editCache: ?string;
|
@observable editCache: ?string;
|
||||||
|
@observable document: ?Document;
|
||||||
@observable newDocument: ?Document;
|
@observable newDocument: ?Document;
|
||||||
@observable isLoading = false;
|
@observable isLoading = false;
|
||||||
@observable isSaving = false;
|
@observable isSaving = false;
|
||||||
@@ -90,7 +91,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
|
|
||||||
loadDocument = async props => {
|
loadDocument = async props => {
|
||||||
if (props.newDocument) {
|
if (props.newDocument) {
|
||||||
const newDocument = new Document({
|
this.document = new Document({
|
||||||
collection: { id: props.match.params.id },
|
collection: { id: props.match.params.id },
|
||||||
parentDocument: new URLSearchParams(props.location.search).get(
|
parentDocument: new URLSearchParams(props.location.search).get(
|
||||||
'parentDocument'
|
'parentDocument'
|
||||||
@@ -98,32 +99,30 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
title: '',
|
title: '',
|
||||||
text: '',
|
text: '',
|
||||||
});
|
});
|
||||||
this.newDocument = newDocument;
|
|
||||||
} else {
|
} else {
|
||||||
let document = this.getDocument(props.match.params.documentSlug);
|
this.document = await this.props.documents.fetch(
|
||||||
|
props.match.params.documentSlug,
|
||||||
|
{ shareId: props.match.params.shareId }
|
||||||
|
);
|
||||||
|
|
||||||
if (document) {
|
const document = this.document;
|
||||||
this.props.documents.fetch(props.match.params.documentSlug);
|
|
||||||
this.props.ui.setActiveDocument(document);
|
|
||||||
} else {
|
|
||||||
document = await this.props.documents.fetch(
|
|
||||||
props.match.params.documentSlug
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document) {
|
if (document) {
|
||||||
this.props.ui.setActiveDocument(document);
|
this.props.ui.setActiveDocument(document);
|
||||||
|
|
||||||
// Cache data if user enters edit mode and cancels
|
// Cache data if user enters edit mode and cancels
|
||||||
this.editCache = document.text;
|
this.editCache = document.text;
|
||||||
if (!this.isEditing && document.publishedAt) {
|
|
||||||
document.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update url to match the current one
|
if (this.props.auth.user) {
|
||||||
this.props.history.replace(
|
if (!this.isEditing && document.publishedAt) {
|
||||||
updateDocumentUrl(props.match.url, document.url)
|
document.view();
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Update url to match the current one
|
||||||
|
this.props.history.replace(
|
||||||
|
updateDocumentUrl(props.match.url, document.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Render 404 with search
|
// Render 404 with search
|
||||||
this.notFound = true;
|
this.notFound = true;
|
||||||
@@ -137,22 +136,14 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
get isEditing() {
|
get isEditing() {
|
||||||
|
const document = this.document;
|
||||||
|
|
||||||
return !!(
|
return !!(
|
||||||
this.props.match.path === matchDocumentEdit || this.props.newDocument
|
this.props.match.path === matchDocumentEdit ||
|
||||||
|
(document && !document.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocument(documentSlug: ?string) {
|
|
||||||
if (this.newDocument) return this.newDocument;
|
|
||||||
return this.props.documents.getByUrl(
|
|
||||||
`/doc/${documentSlug || this.props.match.params.documentSlug}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get document() {
|
|
||||||
return this.getDocument();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
||||||
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
||||||
|
|
||||||
@@ -162,6 +153,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
let document = this.document;
|
let document = this.document;
|
||||||
if (!document || !document.allowSave) return;
|
if (!document || !document.allowSave) return;
|
||||||
|
|
||||||
|
let isNew = !document.id;
|
||||||
this.editCache = null;
|
this.editCache = null;
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.isPublishing = !!options.publish;
|
this.isPublishing = !!options.publish;
|
||||||
@@ -172,7 +164,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
if (options.done) {
|
if (options.done) {
|
||||||
this.props.history.push(document.url);
|
this.props.history.push(document.url);
|
||||||
this.props.ui.setActiveDocument(document);
|
this.props.ui.setActiveDocument(document);
|
||||||
} else if (this.props.newDocument) {
|
} else if (isNew) {
|
||||||
this.props.history.push(documentEditUrl(document));
|
this.props.history.push(documentEditUrl(document));
|
||||||
this.props.ui.setActiveDocument(document);
|
this.props.ui.setActiveDocument(document);
|
||||||
}
|
}
|
||||||
@@ -237,19 +229,20 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { location, match } = this.props;
|
||||||
const Editor = this.editorComponent;
|
const Editor = this.editorComponent;
|
||||||
const isMoving = this.props.match.path === matchDocumentMove;
|
const isMoving = match.path === matchDocumentMove;
|
||||||
const document = this.document;
|
const document = this.document;
|
||||||
const titleText =
|
const titleFromState = location.state ? location.state.title : '';
|
||||||
get(document, 'title', '') ||
|
const titleText = document ? document.title : titleFromState;
|
||||||
this.props.collections.titleForDocument(this.props.location.pathname);
|
const isShare = match.params.shareId;
|
||||||
|
|
||||||
if (this.notFound) {
|
if (this.notFound) {
|
||||||
return <Search notFound />;
|
return isShare ? <Error404 /> : <Search notFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container key={this.props.location.pathname} column auto>
|
<Container key={document ? document.id : undefined} column auto>
|
||||||
{isMoving && document && <DocumentMove document={document} />}
|
{isMoving && document && <DocumentMove document={document} />}
|
||||||
{titleText && <PageTitle title={titleText} />}
|
{titleText && <PageTitle title={titleText} />}
|
||||||
{(this.isLoading || this.isSaving) && <LoadingIndicator />}
|
{(this.isLoading || this.isSaving) && <LoadingIndicator />}
|
||||||
@@ -282,19 +275,20 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
readOnly={!this.isEditing}
|
readOnly={!this.isEditing}
|
||||||
/>
|
/>
|
||||||
</MaxWidth>
|
</MaxWidth>
|
||||||
{document && (
|
{document &&
|
||||||
<Actions
|
!isShare && (
|
||||||
document={document}
|
<Actions
|
||||||
isDraft={!document.publishedAt}
|
document={document}
|
||||||
isEditing={this.isEditing}
|
isDraft={!document.publishedAt}
|
||||||
isSaving={this.isSaving}
|
isEditing={this.isEditing}
|
||||||
isPublishing={this.isPublishing}
|
isSaving={this.isSaving}
|
||||||
savingIsDisabled={!document.allowSave}
|
isPublishing={this.isPublishing}
|
||||||
history={this.props.history}
|
savingIsDisabled={!document.allowSave}
|
||||||
onDiscard={this.onDiscard}
|
history={this.props.history}
|
||||||
onSave={this.onSave}
|
onDiscard={this.onDiscard}
|
||||||
/>
|
onSave={this.onSave}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -322,6 +316,4 @@ const LoadingState = styled(LoadingPlaceholder)`
|
|||||||
margin: 90px 0;
|
margin: 90px 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withRouter(
|
export default withRouter(inject('ui', 'auth', 'documents')(DocumentScene));
|
||||||
inject('ui', 'user', 'documents', 'collections')(DocumentScene)
|
|
||||||
);
|
|
||||||
|
|||||||
68
app/scenes/DocumentShare/DocumentShare.js
Normal file
68
app/scenes/DocumentShare/DocumentShare.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observable } from 'mobx';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Input from 'components/Input';
|
||||||
|
import Button from 'components/Button';
|
||||||
|
import CopyToClipboard from 'components/CopyToClipboard';
|
||||||
|
import HelpText from 'components/HelpText';
|
||||||
|
import Document from 'models/Document';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
document?: Document,
|
||||||
|
onSubmit: () => *,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class DocumentShare extends React.Component<Props> {
|
||||||
|
@observable isCopied: boolean;
|
||||||
|
timeout: TimeoutID;
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCopied = () => {
|
||||||
|
this.isCopied = true;
|
||||||
|
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.isCopied = false;
|
||||||
|
this.props.onSubmit();
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { document, onSubmit } = this.props;
|
||||||
|
if (!document) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HelpText>
|
||||||
|
The link below allows anyone in the world to access a read-only
|
||||||
|
version of the document <strong>{document.title}</strong>. You can
|
||||||
|
revoke this link in settings at any time.{' '}
|
||||||
|
<Link to="/settings/shares" onClick={onSubmit}>
|
||||||
|
Manage share links
|
||||||
|
</Link>.
|
||||||
|
</HelpText>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Share link"
|
||||||
|
value={document.shareUrl || 'Loading…'}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={document.shareUrl || ''}
|
||||||
|
onCopy={this.handleCopied}
|
||||||
|
>
|
||||||
|
<Button type="submit" disabled={this.isCopied} primary>
|
||||||
|
{this.isCopied ? 'Copied!' : 'Copy Link'}
|
||||||
|
</Button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocumentShare;
|
||||||
3
app/scenes/DocumentShare/index.js
Normal file
3
app/scenes/DocumentShare/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// @flow
|
||||||
|
import DocumentShare from './DocumentShare';
|
||||||
|
export default DocumentShare;
|
||||||
@@ -1,25 +1,19 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
class Error404 extends React.Component<*> {
|
const Error404 = () => {
|
||||||
render() {
|
return (
|
||||||
return (
|
<CenteredContent>
|
||||||
<CenteredContent>
|
<PageTitle title="Not Found" />
|
||||||
<PageTitle title="Not found" />
|
<h1>Not Found</h1>
|
||||||
<h1>Not Found</h1>
|
<p>We were unable to find the page you’re looking for.</p>
|
||||||
|
<p>
|
||||||
<p>We're unable to find the page you're accessing.</p>
|
Go to <a href="/">homepage</a>.
|
||||||
|
</p>
|
||||||
<p>
|
</CenteredContent>
|
||||||
Maybe you want to try <Link to="/search">search</Link> instead?
|
);
|
||||||
</p>
|
};
|
||||||
</CenteredContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Error404;
|
export default Error404;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class Search extends React.Component<Props> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
loadMoreResults = async () => {
|
loadMoreResults = async () => {
|
||||||
// Don't paginate if there aren't more results or we're in the middle of fetching
|
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||||
if (!this.allowLoadMore || this.isFetching) return;
|
if (!this.allowLoadMore || this.isFetching) return;
|
||||||
|
|
||||||
// Fetch more results
|
// Fetch more results
|
||||||
|
|||||||
48
app/scenes/Settings/Members.js
Normal file
48
app/scenes/Settings/Members.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import invariant from 'invariant';
|
||||||
|
import { observer, inject } from 'mobx-react';
|
||||||
|
|
||||||
|
import AuthStore from 'stores/AuthStore';
|
||||||
|
import UsersStore from 'stores/UsersStore';
|
||||||
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
import UserListItem from './components/UserListItem';
|
||||||
|
import List from 'components/List';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
auth: AuthStore,
|
||||||
|
users: UsersStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class Members extends React.Component<Props> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.users.fetchPage({ limit: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { users, auth } = this.props;
|
||||||
|
const currentUser = auth.user;
|
||||||
|
invariant(currentUser, 'User should exist');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredContent>
|
||||||
|
<PageTitle title="Members" />
|
||||||
|
<h1>Members</h1>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{users.data.map(user => (
|
||||||
|
<UserListItem
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
isCurrentUser={currentUser.id === user.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CenteredContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('auth', 'users')(Members);
|
||||||
38
app/scenes/Settings/Shares.js
Normal file
38
app/scenes/Settings/Shares.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observer, inject } from 'mobx-react';
|
||||||
|
import SharesStore from 'stores/SharesStore';
|
||||||
|
|
||||||
|
import ShareListItem from './components/ShareListItem';
|
||||||
|
import List from 'components/List';
|
||||||
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
shares: SharesStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class Shares extends React.Component<Props> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.shares.fetchPage({ limit: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { shares } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredContent>
|
||||||
|
<PageTitle title="Share Links" />
|
||||||
|
<h1>Share Links</h1>
|
||||||
|
<List>
|
||||||
|
{shares.orderedData.map(share => (
|
||||||
|
<ShareListItem key={share.id} share={share} />
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CenteredContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('shares')(Shares);
|
||||||
@@ -3,17 +3,15 @@ import * as React from 'react';
|
|||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
|
||||||
import ApiToken from './components/ApiToken';
|
|
||||||
import ApiKeysStore from 'stores/ApiKeysStore';
|
import ApiKeysStore from 'stores/ApiKeysStore';
|
||||||
import { color } from 'shared/styles/constants';
|
|
||||||
|
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import Input from 'components/Input';
|
import Input from 'components/Input';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
import Subheading from 'components/Subheading';
|
import List from 'components/List';
|
||||||
|
import TokenListItem from './components/TokenListItem';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
apiKeys: ApiKeysStore,
|
apiKeys: ApiKeysStore,
|
||||||
@@ -46,29 +44,23 @@ class Tokens extends React.Component<Props> {
|
|||||||
<PageTitle title="API Tokens" />
|
<PageTitle title="API Tokens" />
|
||||||
<h1>API Tokens</h1>
|
<h1>API Tokens</h1>
|
||||||
|
|
||||||
{hasApiKeys && [
|
|
||||||
<Subheading>Your tokens</Subheading>,
|
|
||||||
<Table>
|
|
||||||
<tbody>
|
|
||||||
{apiKeys.data.map(key => (
|
|
||||||
<ApiToken
|
|
||||||
id={key.id}
|
|
||||||
key={key.id}
|
|
||||||
name={key.name}
|
|
||||||
secret={key.secret}
|
|
||||||
onDelete={apiKeys.deleteApiKey}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>,
|
|
||||||
<Subheading>Create a token</Subheading>,
|
|
||||||
]}
|
|
||||||
|
|
||||||
<HelpText>
|
<HelpText>
|
||||||
You can create unlimited personal API tokens to hack on your wiki.
|
You can create unlimited personal API tokens to hack on your wiki.
|
||||||
Learn more in the <Link to="/developers">API documentation</Link>.
|
Learn more in the <Link to="/developers">API documentation</Link>.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
|
||||||
|
{hasApiKeys && (
|
||||||
|
<List>
|
||||||
|
{apiKeys.data.map(token => (
|
||||||
|
<TokenListItem
|
||||||
|
key={token.id}
|
||||||
|
token={token}
|
||||||
|
onDelete={apiKeys.deleteApiKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
<Input
|
<Input
|
||||||
onChange={this.handleUpdate}
|
onChange={this.handleUpdate}
|
||||||
@@ -87,14 +79,4 @@ class Tokens extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = styled.table`
|
|
||||||
margin-bottom: 30px;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
td {
|
|
||||||
margin-right: 20px;
|
|
||||||
color: ${color.slate};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default inject('apiKeys')(Tokens);
|
export default inject('apiKeys')(Tokens);
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
// @flow
|
|
||||||
import * as React from 'react';
|
|
||||||
import invariant from 'invariant';
|
|
||||||
import { observer, inject } from 'mobx-react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import Flex from 'shared/components/Flex';
|
|
||||||
import Avatar from 'components/Avatar';
|
|
||||||
import { color } from 'shared/styles/constants';
|
|
||||||
|
|
||||||
import AuthStore from 'stores/AuthStore';
|
|
||||||
import ErrorsStore from 'stores/ErrorsStore';
|
|
||||||
import UsersStore from 'stores/UsersStore';
|
|
||||||
import CenteredContent from 'components/CenteredContent';
|
|
||||||
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
|
||||||
import PageTitle from 'components/PageTitle';
|
|
||||||
import UserMenu from './components/UserMenu';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
auth: AuthStore,
|
|
||||||
errors: ErrorsStore,
|
|
||||||
users: UsersStore,
|
|
||||||
};
|
|
||||||
|
|
||||||
@observer
|
|
||||||
class Users extends React.Component<Props> {
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.users.fetchPage({ limit: 100 });
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const currentUser = this.props.auth.user;
|
|
||||||
invariant(currentUser, 'User should exist');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CenteredContent>
|
|
||||||
<PageTitle title="Users" />
|
|
||||||
<h1>Users</h1>
|
|
||||||
|
|
||||||
{!this.props.users.isLoaded ? (
|
|
||||||
<Flex column>
|
|
||||||
{this.props.users.data && (
|
|
||||||
<UserList column>
|
|
||||||
{this.props.users.data.map(user => (
|
|
||||||
<User key={user.id} justify="space-between" auto>
|
|
||||||
<UserDetails suspended={user.isSuspended}>
|
|
||||||
<Avatar src={user.avatarUrl} />
|
|
||||||
<UserName>
|
|
||||||
{user.name} {user.email && `(${user.email})`}
|
|
||||||
{user.isAdmin && (
|
|
||||||
<Badge admin={user.isAdmin}>Admin</Badge>
|
|
||||||
)}
|
|
||||||
{user.isSuspended && <Badge>Suspended</Badge>}
|
|
||||||
</UserName>
|
|
||||||
</UserDetails>
|
|
||||||
<Flex>
|
|
||||||
{currentUser.id !== user.id && <UserMenu user={user} />}
|
|
||||||
</Flex>
|
|
||||||
</User>
|
|
||||||
))}
|
|
||||||
</UserList>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<LoadingPlaceholder />
|
|
||||||
)}
|
|
||||||
</CenteredContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserList = styled(Flex)`
|
|
||||||
border: 1px solid ${color.smoke};
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const User = styled(Flex)`
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid ${color.smoke};
|
|
||||||
font-size: 15px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UserDetails = styled(Flex)`
|
|
||||||
opacity: ${({ suspended }) => (suspended ? 0.5 : 1)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UserName = styled.span`
|
|
||||||
padding-left: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Badge = styled.span`
|
|
||||||
margin-left: 10px;
|
|
||||||
padding: 2px 6px 3px;
|
|
||||||
background-color: ${({ admin }) => (admin ? color.primary : color.smokeDark)};
|
|
||||||
color: ${({ admin }) => (admin ? color.white : color.text)};
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: normal;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default inject('auth', 'errors', 'users')(Users);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// @flow
|
|
||||||
import * as React from 'react';
|
|
||||||
import { observable } from 'mobx';
|
|
||||||
import { observer } from 'mobx-react';
|
|
||||||
import Button from 'components/Button';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
id: string,
|
|
||||||
name: ?string,
|
|
||||||
secret: string,
|
|
||||||
onDelete: (id: string) => *,
|
|
||||||
};
|
|
||||||
|
|
||||||
@observer
|
|
||||||
class ApiToken extends React.Component<Props> {
|
|
||||||
@observable disabled: boolean;
|
|
||||||
|
|
||||||
onClick = () => {
|
|
||||||
this.props.onDelete(this.props.id);
|
|
||||||
this.disabled = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { name, secret } = this.props;
|
|
||||||
const { disabled } = this;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td>{name}</td>
|
|
||||||
<td>
|
|
||||||
<code>{secret}</code>
|
|
||||||
</td>
|
|
||||||
<td align="right">
|
|
||||||
<Button onClick={this.onClick} disabled={disabled} neutral>
|
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ApiToken;
|
|
||||||
31
app/scenes/Settings/components/ShareListItem.js
Normal file
31
app/scenes/Settings/components/ShareListItem.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||||
|
import ShareMenu from 'menus/ShareMenu';
|
||||||
|
import ListItem from 'components/List/Item';
|
||||||
|
import type { Share } from '../../../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
share: Share,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShareListItem = ({ share }: Props) => {
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={share.id}
|
||||||
|
title={share.documentTitle}
|
||||||
|
subtitle={
|
||||||
|
<React.Fragment>
|
||||||
|
Shared{' '}
|
||||||
|
<time dateTime={share.createdAt}>
|
||||||
|
{distanceInWordsToNow(new Date(share.createdAt))}
|
||||||
|
</time>{' '}
|
||||||
|
ago by {share.createdBy.name}
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
actions={<ShareMenu share={share} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShareListItem;
|
||||||
27
app/scenes/Settings/components/TokenListItem.js
Normal file
27
app/scenes/Settings/components/TokenListItem.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import Button from 'components/Button';
|
||||||
|
import ListItem from 'components/List/Item';
|
||||||
|
import type { ApiKey } from '../../../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
token: ApiKey,
|
||||||
|
onDelete: (tokenId: string) => *,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TokenListItem = ({ token, onDelete }: Props) => {
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={token.id}
|
||||||
|
title={token.name}
|
||||||
|
subtitle={<code>{token.secret}</code>}
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => onDelete(token.id)} light>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TokenListItem;
|
||||||
45
app/scenes/Settings/components/UserListItem.js
Normal file
45
app/scenes/Settings/components/UserListItem.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { color } from 'shared/styles/constants';
|
||||||
|
|
||||||
|
import UserMenu from 'menus/UserMenu';
|
||||||
|
import Avatar from 'components/Avatar';
|
||||||
|
import ListItem from 'components/List/Item';
|
||||||
|
import type { User } from '../../../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: User,
|
||||||
|
isCurrentUser: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserListItem = ({ user, isCurrentUser }: Props) => {
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={user.id}
|
||||||
|
title={user.name}
|
||||||
|
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||||
|
subtitle={
|
||||||
|
<React.Fragment>
|
||||||
|
{user.username ? user.username : user.email}
|
||||||
|
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||||
|
{user.isSuspended && <Badge>Suspended</Badge>}
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
actions={isCurrentUser ? undefined : <UserMenu user={user} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Badge = styled.span`
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 2px 6px 3px;
|
||||||
|
background-color: ${({ admin }) => (admin ? color.primary : color.smokeDark)};
|
||||||
|
color: ${({ admin }) => (admin ? color.white : color.text)};
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: normal;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default UserListItem;
|
||||||
@@ -1,30 +1,25 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import {
|
import { observable, action, computed, ObservableMap, runInAction } from 'mobx';
|
||||||
observable,
|
|
||||||
action,
|
|
||||||
computed,
|
|
||||||
ObservableMap,
|
|
||||||
runInAction,
|
|
||||||
autorunAsync,
|
|
||||||
} from 'mobx';
|
|
||||||
import { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
|
|
||||||
import BaseStore from 'stores/BaseStore';
|
import BaseStore from 'stores/BaseStore';
|
||||||
import stores from 'stores';
|
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import ErrorsStore from 'stores/ErrorsStore';
|
import ErrorsStore from 'stores/ErrorsStore';
|
||||||
import CacheStore from 'stores/CacheStore';
|
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import type { PaginationParams } from 'types';
|
import type { PaginationParams } from 'types';
|
||||||
|
|
||||||
const DOCUMENTS_CACHE_KEY = 'DOCUMENTS_CACHE_KEY';
|
|
||||||
export const DEFAULT_PAGINATION_LIMIT = 25;
|
export const DEFAULT_PAGINATION_LIMIT = 25;
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
cache: CacheStore,
|
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
|
errors: ErrorsStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchOptions = {
|
||||||
|
prefetch?: boolean,
|
||||||
|
shareId?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
class DocumentsStore extends BaseStore {
|
class DocumentsStore extends BaseStore {
|
||||||
@@ -35,7 +30,6 @@ class DocumentsStore extends BaseStore {
|
|||||||
@observable isFetching: boolean = false;
|
@observable isFetching: boolean = false;
|
||||||
|
|
||||||
errors: ErrorsStore;
|
errors: ErrorsStore;
|
||||||
cache: CacheStore;
|
|
||||||
ui: UiStore;
|
ui: UiStore;
|
||||||
|
|
||||||
/* Computed */
|
/* Computed */
|
||||||
@@ -178,15 +172,23 @@ class DocumentsStore extends BaseStore {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
prefetchDocument = async (id: string) => {
|
prefetchDocument = async (id: string) => {
|
||||||
if (!this.getById(id)) this.fetch(id, true);
|
if (!this.getById(id)) {
|
||||||
|
this.fetch(id, { prefetch: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
fetch = async (id: string, prefetch?: boolean): Promise<*> => {
|
fetch = async (id: string, options?: FetchOptions = {}): Promise<*> => {
|
||||||
if (!prefetch) this.isFetching = true;
|
if (!options.prefetch) this.isFetching = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await client.post('/documents.info', { id });
|
const doc = this.getById(id) || this.getByUrl(id);
|
||||||
|
if (doc) return doc;
|
||||||
|
|
||||||
|
const res = await client.post('/documents.info', {
|
||||||
|
id,
|
||||||
|
shareId: options.shareId,
|
||||||
|
});
|
||||||
invariant(res && res.data, 'Document not available');
|
invariant(res && res.data, 'Document not available');
|
||||||
const { data } = res;
|
const { data } = res;
|
||||||
const document = new Document(data);
|
const document = new Document(data);
|
||||||
@@ -198,7 +200,7 @@ class DocumentsStore extends BaseStore {
|
|||||||
|
|
||||||
return document;
|
return document;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errors.add('Failed to load documents');
|
this.errors.add('Failed to load document');
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
}
|
}
|
||||||
@@ -228,16 +230,9 @@ class DocumentsStore extends BaseStore {
|
|||||||
constructor(options: Options) {
|
constructor(options: Options) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.errors = stores.errors;
|
this.errors = options.errors;
|
||||||
this.cache = options.cache;
|
|
||||||
this.ui = options.ui;
|
this.ui = options.ui;
|
||||||
|
|
||||||
this.cache.getItem(DOCUMENTS_CACHE_KEY).then(data => {
|
|
||||||
if (data) {
|
|
||||||
data.forEach(document => this.add(new Document(document)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.on('documents.delete', (data: { id: string }) => {
|
this.on('documents.delete', (data: { id: string }) => {
|
||||||
this.remove(data.id);
|
this.remove(data.id);
|
||||||
});
|
});
|
||||||
@@ -254,15 +249,6 @@ class DocumentsStore extends BaseStore {
|
|||||||
this.fetchRecentlyModified();
|
this.fetchRecentlyModified();
|
||||||
this.fetchRecentlyViewed();
|
this.fetchRecentlyViewed();
|
||||||
});
|
});
|
||||||
|
|
||||||
autorunAsync('DocumentsStore.persists', () => {
|
|
||||||
if (this.data.size) {
|
|
||||||
this.cache.setItem(
|
|
||||||
DOCUMENTS_CACHE_KEY,
|
|
||||||
Array.from(this.data.values()).map(collection => collection.data)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
app/stores/SharesStore.js
Normal file
51
app/stores/SharesStore.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// @flow
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { observable, action, runInAction, ObservableMap, computed } from 'mobx';
|
||||||
|
import invariant from 'invariant';
|
||||||
|
import { client } from 'utils/ApiClient';
|
||||||
|
import type { Share, PaginationParams } from 'types';
|
||||||
|
|
||||||
|
class SharesStore {
|
||||||
|
@observable data: Map<string, Share> = new ObservableMap([]);
|
||||||
|
@observable isFetching: boolean = false;
|
||||||
|
@observable isSaving: boolean = false;
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get orderedData(): Share[] {
|
||||||
|
return _.sortBy(this.data.values(), 'createdAt').reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchPage = async (options: ?PaginationParams): Promise<*> => {
|
||||||
|
this.isFetching = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.post('/shares.list', options);
|
||||||
|
invariant(res && res.data, 'Data should be available');
|
||||||
|
const { data } = res;
|
||||||
|
|
||||||
|
runInAction('fetchShares', () => {
|
||||||
|
data.forEach(share => {
|
||||||
|
this.data.set(share.id, share);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Something went wrong');
|
||||||
|
}
|
||||||
|
this.isFetching = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
revoke = async (share: Share) => {
|
||||||
|
try {
|
||||||
|
await client.post('/shares.delete', { id: share.id });
|
||||||
|
runInAction('revoke', () => {
|
||||||
|
this.data.delete(share.id);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Something went wrong');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SharesStore;
|
||||||
@@ -30,7 +30,7 @@ class UiStore {
|
|||||||
this.activeDocumentId = document.id;
|
this.activeDocumentId = document.id;
|
||||||
|
|
||||||
if (document.publishedAt) {
|
if (document.publishedAt) {
|
||||||
this.activeCollectionId = document.collection.id;
|
this.activeCollectionId = document.collectionId;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { User, PaginationParams } from 'types';
|
|||||||
|
|
||||||
class UsersStore {
|
class UsersStore {
|
||||||
@observable data: User[] = [];
|
@observable data: User[] = [];
|
||||||
@observable isLoaded: boolean = false;
|
|
||||||
@observable isSaving: boolean = false;
|
@observable isSaving: boolean = false;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -22,7 +21,6 @@ class UsersStore {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Something went wrong');
|
console.error('Something went wrong');
|
||||||
}
|
}
|
||||||
this.isLoaded = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
import AuthStore from './AuthStore';
|
import AuthStore from './AuthStore';
|
||||||
import UiStore from './UiStore';
|
import UiStore from './UiStore';
|
||||||
import ErrorsStore from './ErrorsStore';
|
import ErrorsStore from './ErrorsStore';
|
||||||
|
import DocumentsStore from './DocumentsStore';
|
||||||
|
import SharesStore from './SharesStore';
|
||||||
|
|
||||||
|
const ui = new UiStore();
|
||||||
|
const errors = new ErrorsStore();
|
||||||
const stores = {
|
const stores = {
|
||||||
user: null, // Including for Layout
|
user: null, // Including for Layout
|
||||||
auth: new AuthStore(),
|
auth: new AuthStore(),
|
||||||
ui: new UiStore(),
|
ui,
|
||||||
errors: new ErrorsStore(),
|
errors,
|
||||||
|
documents: new DocumentsStore({ ui, errors }),
|
||||||
|
shares: new SharesStore(),
|
||||||
};
|
};
|
||||||
window.stores = stores;
|
|
||||||
|
|
||||||
export default stores;
|
export default stores;
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ export type User = {
|
|||||||
isSuspended?: boolean,
|
isSuspended?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Share = {
|
||||||
|
id: string,
|
||||||
|
url: string,
|
||||||
|
documentTitle: string,
|
||||||
|
documentUrl: string,
|
||||||
|
createdBy: User,
|
||||||
|
createdAt: string,
|
||||||
|
updatedAt: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type Team = {
|
export type Team = {
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -29,7 +39,6 @@ export type Document = {
|
|||||||
createdBy: User,
|
createdBy: User,
|
||||||
html: string,
|
html: string,
|
||||||
id: string,
|
id: string,
|
||||||
private: boolean,
|
|
||||||
starred: boolean,
|
starred: boolean,
|
||||||
views: number,
|
views: number,
|
||||||
team: string,
|
team: string,
|
||||||
@@ -58,6 +67,6 @@ export type PaginationParams = {
|
|||||||
|
|
||||||
export type ApiKey = {
|
export type ApiKey = {
|
||||||
id: string,
|
id: string,
|
||||||
name: ?string,
|
name: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"test": "npm run test:app && npm run test:server",
|
"test": "npm run test:app && npm run test:server",
|
||||||
"test:app": "jest",
|
"test:app": "jest",
|
||||||
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
|
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
|
||||||
|
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll",
|
||||||
"precommit": "lint-staged"
|
"precommit": "lint-staged"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
19
server/api/__snapshots__/shares.test.js.snap
Normal file
19
server/api/__snapshots__/shares.test.js.snap
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`#shares.create should require authentication 1`] = `
|
||||||
|
Object {
|
||||||
|
"error": "authentication_required",
|
||||||
|
"message": "Authentication required",
|
||||||
|
"ok": false,
|
||||||
|
"status": 401,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`#shares.list should require authentication 1`] = `
|
||||||
|
Object {
|
||||||
|
"error": "authentication_required",
|
||||||
|
"message": "Authentication required",
|
||||||
|
"ok": false,
|
||||||
|
"status": 401,
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -4,13 +4,13 @@ import Sequelize from 'sequelize';
|
|||||||
import auth from './middlewares/authentication';
|
import auth from './middlewares/authentication';
|
||||||
import pagination from './middlewares/pagination';
|
import pagination from './middlewares/pagination';
|
||||||
import { presentDocument, presentRevision } from '../presenters';
|
import { presentDocument, presentRevision } from '../presenters';
|
||||||
import { Document, Collection, Star, View, Revision } from '../models';
|
import { Document, Collection, Share, Star, View, Revision } from '../models';
|
||||||
import { InvalidRequestError } from '../errors';
|
import { InvalidRequestError } from '../errors';
|
||||||
import events from '../events';
|
import events from '../events';
|
||||||
import policy from '../policies';
|
import policy from '../policies';
|
||||||
|
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
const { authorize } = policy;
|
const { authorize, cannot } = policy;
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post('documents.list', auth(), pagination(), async ctx => {
|
router.post('documents.list', auth(), pagination(), async ctx => {
|
||||||
@@ -157,15 +157,36 @@ router.post('documents.drafts', auth(), pagination(), async ctx => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('documents.info', auth(), async ctx => {
|
router.post('documents.info', auth({ required: false }), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id, shareId } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id || shareId, 'id or shareId is required');
|
||||||
const document = await Document.findById(id);
|
|
||||||
|
|
||||||
authorize(ctx.state.user, 'read', document);
|
const user = ctx.state.user;
|
||||||
|
let document;
|
||||||
|
|
||||||
|
if (shareId) {
|
||||||
|
const share = await Share.findById(shareId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Document,
|
||||||
|
required: true,
|
||||||
|
as: 'document',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!share) {
|
||||||
|
throw new InvalidRequestError('Document could not be found for shareId');
|
||||||
|
}
|
||||||
|
document = share.document;
|
||||||
|
} else {
|
||||||
|
document = await Document.findById(id);
|
||||||
|
authorize(user, 'read', document);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublic = cannot(user, 'read', document);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(ctx, document, { isPublic }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import TestServer from 'fetch-test-server';
|
|||||||
import app from '..';
|
import app from '..';
|
||||||
import { Document, View, Star, Revision } from '../models';
|
import { Document, View, Star, Revision } from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser } from '../test/factories';
|
import { buildShare, buildUser } from '../test/factories';
|
||||||
|
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
|
|
||||||
@@ -35,6 +35,68 @@ describe('#documents.info', async () => {
|
|||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.id).toEqual(document.id);
|
expect(body.data.id).toEqual(document.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return redacted documents from shareId without token', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: document.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.info', {
|
||||||
|
body: { shareId: share.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.id).toEqual(document.id);
|
||||||
|
expect(body.data.collection).toEqual(undefined);
|
||||||
|
expect(body.data.createdBy).toEqual(undefined);
|
||||||
|
expect(body.data.updatedBy).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return documents from shareId with token', async () => {
|
||||||
|
const { user, document, collection } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: document.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.info', {
|
||||||
|
body: { token: user.getJwtToken(), shareId: share.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.id).toEqual(document.id);
|
||||||
|
expect(body.data.collection.id).toEqual(collection.id);
|
||||||
|
expect(body.data.createdBy.id).toEqual(user.id);
|
||||||
|
expect(body.data.updatedBy.id).toEqual(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authorization without token', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const res = await server.post('/api/documents.info', {
|
||||||
|
body: { id: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authorization with incorrect token', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post('/api/documents.info', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid shareId', async () => {
|
||||||
|
const res = await server.post('/api/documents.info', {
|
||||||
|
body: { shareId: 123 },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#documents.list', async () => {
|
describe('#documents.list', async () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import documents from './documents';
|
|||||||
import views from './views';
|
import views from './views';
|
||||||
import hooks from './hooks';
|
import hooks from './hooks';
|
||||||
import apiKeys from './apiKeys';
|
import apiKeys from './apiKeys';
|
||||||
|
import shares from './shares';
|
||||||
import team from './team';
|
import team from './team';
|
||||||
import integrations from './integrations';
|
import integrations from './integrations';
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ router.use('/', documents.routes());
|
|||||||
router.use('/', views.routes());
|
router.use('/', views.routes());
|
||||||
router.use('/', hooks.routes());
|
router.use('/', hooks.routes());
|
||||||
router.use('/', apiKeys.routes());
|
router.use('/', apiKeys.routes());
|
||||||
|
router.use('/', shares.routes());
|
||||||
router.use('/', team.routes());
|
router.use('/', team.routes());
|
||||||
router.use('/', integrations.routes());
|
router.use('/', integrations.routes());
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { type Context } from 'koa';
|
|||||||
import { User, ApiKey } from '../../models';
|
import { User, ApiKey } from '../../models';
|
||||||
import { AuthenticationError, UserSuspendedError } from '../../errors';
|
import { AuthenticationError, UserSuspendedError } from '../../errors';
|
||||||
|
|
||||||
export default function auth() {
|
export default function auth(options?: { required?: boolean } = {}) {
|
||||||
return async function authMiddleware(
|
return async function authMiddleware(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
next: () => Promise<void>
|
next: () => Promise<void>
|
||||||
@@ -33,58 +33,61 @@ export default function auth() {
|
|||||||
token = ctx.request.query.token;
|
token = ctx.request.query.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) throw new AuthenticationError('Authentication required');
|
if (!token && options.required !== false) {
|
||||||
|
throw new AuthenticationError('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
|
if (token) {
|
||||||
|
if (String(token).match(/^[\w]{38}$/)) {
|
||||||
|
// API key
|
||||||
|
let apiKey;
|
||||||
|
try {
|
||||||
|
apiKey = await ApiKey.findOne({
|
||||||
|
where: {
|
||||||
|
secret: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new AuthenticationError('Invalid API key');
|
||||||
|
}
|
||||||
|
|
||||||
if (String(token).match(/^[\w]{38}$/)) {
|
if (!apiKey) throw new AuthenticationError('Invalid API key');
|
||||||
// API key
|
|
||||||
let apiKey;
|
user = await User.findById(apiKey.userId);
|
||||||
try {
|
if (!user) throw new AuthenticationError('Invalid API key');
|
||||||
apiKey = await ApiKey.findOne({
|
} else {
|
||||||
where: {
|
// JWT
|
||||||
secret: token,
|
// Get user without verifying payload signature
|
||||||
},
|
let payload;
|
||||||
});
|
try {
|
||||||
} catch (e) {
|
payload = JWT.decode(token);
|
||||||
throw new AuthenticationError('Invalid API key');
|
} catch (e) {
|
||||||
|
throw new AuthenticationError('Unable to decode JWT token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) throw new AuthenticationError('Invalid token');
|
||||||
|
|
||||||
|
user = await User.findById(payload.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JWT.verify(token, user.jwtSecret);
|
||||||
|
} catch (e) {
|
||||||
|
throw new AuthenticationError('Invalid token');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey) throw new AuthenticationError('Invalid API key');
|
if (user.isSuspended) {
|
||||||
|
const suspendingAdmin = await User.findById(user.suspendedById);
|
||||||
user = await User.findById(apiKey.userId);
|
throw new UserSuspendedError({ adminEmail: suspendingAdmin.email });
|
||||||
if (!user) throw new AuthenticationError('Invalid API key');
|
|
||||||
} else {
|
|
||||||
// JWT
|
|
||||||
// Get user without verifying payload signature
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JWT.decode(token);
|
|
||||||
} catch (e) {
|
|
||||||
throw new AuthenticationError('Unable to decode JWT token');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload) throw new AuthenticationError('Invalid token');
|
ctx.state.token = token;
|
||||||
|
ctx.state.user = user;
|
||||||
user = await User.findById(payload.id);
|
// $FlowFixMe
|
||||||
|
ctx.cache[user.id] = user;
|
||||||
try {
|
|
||||||
JWT.verify(token, user.jwtSecret);
|
|
||||||
} catch (e) {
|
|
||||||
throw new AuthenticationError('Invalid token');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isSuspended) {
|
|
||||||
const suspendingAdmin = await User.findById(user.suspendedById);
|
|
||||||
throw new UserSuspendedError({ adminEmail: suspendingAdmin.email });
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.state.token = token;
|
|
||||||
ctx.state.user = user;
|
|
||||||
// $FlowFixMe
|
|
||||||
ctx.cache[user.id] = user;
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
86
server/api/shares.js
Normal file
86
server/api/shares.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// @flow
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import auth from './middlewares/authentication';
|
||||||
|
import pagination from './middlewares/pagination';
|
||||||
|
import { presentShare } from '../presenters';
|
||||||
|
import { Document, User, Share } from '../models';
|
||||||
|
import policy from '../policies';
|
||||||
|
|
||||||
|
const { authorize } = policy;
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post('shares.list', auth(), pagination(), async ctx => {
|
||||||
|
let { sort = 'updatedAt', direction } = ctx.body;
|
||||||
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const where = { teamId: user.teamId, userId: user.id };
|
||||||
|
|
||||||
|
if (user.isAdmin) delete where.userId;
|
||||||
|
|
||||||
|
const shares = await Share.findAll({
|
||||||
|
where,
|
||||||
|
order: [[sort, direction]],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Document,
|
||||||
|
required: true,
|
||||||
|
as: 'document',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
required: true,
|
||||||
|
as: 'user',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await Promise.all(shares.map(share => presentShare(ctx, share)));
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('shares.create', auth(), async ctx => {
|
||||||
|
const { documentId } = ctx.body;
|
||||||
|
ctx.assertPresent(documentId, 'documentId is required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findById(documentId);
|
||||||
|
authorize(user, 'share', document);
|
||||||
|
|
||||||
|
const [share] = await Share.findOrCreate({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
share.user = user;
|
||||||
|
share.document = document;
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: presentShare(ctx, share),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('shares.delete', auth(), async ctx => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const share = await Share.findById(id);
|
||||||
|
authorize(user, 'delete', share);
|
||||||
|
|
||||||
|
await share.destroy();
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
108
server/api/shares.test.js
Normal file
108
server/api/shares.test.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
|
import TestServer from 'fetch-test-server';
|
||||||
|
import app from '..';
|
||||||
|
import { flushdb, seed } from '../test/support';
|
||||||
|
import { buildUser, buildShare } from '../test/factories';
|
||||||
|
|
||||||
|
const server = new TestServer(app.callback());
|
||||||
|
|
||||||
|
beforeEach(flushdb);
|
||||||
|
afterAll(server.close);
|
||||||
|
|
||||||
|
describe('#shares.list', async () => {
|
||||||
|
it('should only return shares created by user', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
const res = await server.post('/api/shares.list', {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
expect(body.data[0].id).toEqual(share.id);
|
||||||
|
expect(body.data[0].documentTitle).toBe(document.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admins should only return shares created by all users', async () => {
|
||||||
|
const { admin, document } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
});
|
||||||
|
const res = await server.post('/api/shares.list', {
|
||||||
|
body: { token: admin.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
expect(body.data[0].id).toEqual(share.id);
|
||||||
|
expect(body.data[0].documentTitle).toBe(document.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const res = await server.post('/api/shares.list');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#shares.create', async () => {
|
||||||
|
it('should allow creating a share record for document', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const res = await server.post('/api/shares.create', {
|
||||||
|
body: { token: user.getJwtToken(), documentId: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.documentTitle).toBe(document.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return existing share link for document and user', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
const res = await server.post('/api/shares.create', {
|
||||||
|
body: { token: user.getJwtToken(), documentId: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.id).toBe(share.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const res = await server.post('/api/shares.create', {
|
||||||
|
body: { documentId: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authorization', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post('/api/shares.create', {
|
||||||
|
body: { token: user.getJwtToken(), documentId: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,5 +15,9 @@ export default (props: Props) => {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
};
|
};
|
||||||
|
|
||||||
return <a {...props} style={style}>{props.children}</a>;
|
return (
|
||||||
|
<a {...props} style={style}>
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
44
server/migrations/20180513041057-add-share-links.js
Normal file
44
server/migrations/20180513041057-add-share-links.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable('shares', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'teams',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'documents',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.dropTable('shares');
|
||||||
|
},
|
||||||
|
};
|
||||||
27
server/models/Share.js
Normal file
27
server/models/Share.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// @flow
|
||||||
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
|
|
||||||
|
const Share = sequelize.define('share', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Share.associate = models => {
|
||||||
|
Share.belongsTo(models.User, {
|
||||||
|
as: 'user',
|
||||||
|
foreignKey: 'userId',
|
||||||
|
});
|
||||||
|
Share.belongsTo(models.Team, {
|
||||||
|
as: 'team',
|
||||||
|
foreignKey: 'teamId',
|
||||||
|
});
|
||||||
|
Share.belongsTo(models.Document, {
|
||||||
|
as: 'document',
|
||||||
|
foreignKey: 'documentId',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Share;
|
||||||
@@ -1,28 +1,30 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
import ApiKey from './ApiKey';
|
||||||
import Authentication from './Authentication';
|
import Authentication from './Authentication';
|
||||||
import Integration from './Integration';
|
|
||||||
import Event from './Event';
|
|
||||||
import User from './User';
|
|
||||||
import Team from './Team';
|
|
||||||
import Collection from './Collection';
|
import Collection from './Collection';
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
|
import Event from './Event';
|
||||||
|
import Integration from './Integration';
|
||||||
import Revision from './Revision';
|
import Revision from './Revision';
|
||||||
import ApiKey from './ApiKey';
|
import Share from './Share';
|
||||||
import View from './View';
|
|
||||||
import Star from './Star';
|
import Star from './Star';
|
||||||
|
import Team from './Team';
|
||||||
|
import User from './User';
|
||||||
|
import View from './View';
|
||||||
|
|
||||||
const models = {
|
const models = {
|
||||||
|
ApiKey,
|
||||||
Authentication,
|
Authentication,
|
||||||
Integration,
|
|
||||||
Event,
|
|
||||||
User,
|
|
||||||
Team,
|
|
||||||
Collection,
|
Collection,
|
||||||
Document,
|
Document,
|
||||||
|
Event,
|
||||||
|
Integration,
|
||||||
Revision,
|
Revision,
|
||||||
ApiKey,
|
Share,
|
||||||
View,
|
|
||||||
Star,
|
Star,
|
||||||
|
Team,
|
||||||
|
User,
|
||||||
|
View,
|
||||||
};
|
};
|
||||||
|
|
||||||
// based on https://github.com/sequelize/express-example/blob/master/models/index.js
|
// based on https://github.com/sequelize/express-example/blob/master/models/index.js
|
||||||
@@ -33,15 +35,16 @@ Object.keys(models).forEach(modelName => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
ApiKey,
|
||||||
Authentication,
|
Authentication,
|
||||||
Integration,
|
|
||||||
Event,
|
|
||||||
User,
|
|
||||||
Team,
|
|
||||||
Collection,
|
Collection,
|
||||||
Document,
|
Document,
|
||||||
|
Event,
|
||||||
|
Integration,
|
||||||
Revision,
|
Revision,
|
||||||
ApiKey,
|
Share,
|
||||||
View,
|
|
||||||
Star,
|
Star,
|
||||||
|
Team,
|
||||||
|
User,
|
||||||
|
View,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ allow(User, 'create', Document);
|
|||||||
|
|
||||||
allow(
|
allow(
|
||||||
User,
|
User,
|
||||||
['read', 'update', 'delete'],
|
['read', 'update', 'delete', 'share'],
|
||||||
Document,
|
Document,
|
||||||
(user, document) => user.teamId === document.teamId
|
(user, document) => user.teamId === document.teamId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import './apiKey';
|
|||||||
import './collection';
|
import './collection';
|
||||||
import './document';
|
import './document';
|
||||||
import './integration';
|
import './integration';
|
||||||
|
import './share';
|
||||||
import './user';
|
import './user';
|
||||||
|
|
||||||
export default policy;
|
export default policy;
|
||||||
|
|||||||
15
server/policies/share.js
Normal file
15
server/policies/share.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// @flow
|
||||||
|
import policy from './policy';
|
||||||
|
import { Share, User } from '../models';
|
||||||
|
import { AdminRequiredError } from '../errors';
|
||||||
|
|
||||||
|
const { allow } = policy;
|
||||||
|
|
||||||
|
allow(User, ['read'], Share, (user, share) => user.teamId === share.teamId);
|
||||||
|
allow(User, ['update'], Share, (user, share) => false);
|
||||||
|
allow(User, ['delete'], Share, (user, share) => {
|
||||||
|
if (!share || user.teamId !== share.teamId) return false;
|
||||||
|
if (user.id === share.userId) return true;
|
||||||
|
if (user.isAdmin) return true;
|
||||||
|
throw new AdminRequiredError();
|
||||||
|
});
|
||||||
@@ -42,8 +42,7 @@ async function present(ctx: Object, collection: Collection) {
|
|||||||
if (collection.documents) {
|
if (collection.documents) {
|
||||||
data.recentDocuments = await Promise.all(
|
data.recentDocuments = await Promise.all(
|
||||||
collection.documents.map(
|
collection.documents.map(
|
||||||
async document =>
|
async document => await presentDocument(ctx, document)
|
||||||
await presentDocument(ctx, document, { includeCollaborators: true })
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import presentCollection from './collection';
|
|||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
includeCollaborators?: boolean,
|
isPublic?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function present(ctx: Object, document: Document, options: ?Options) {
|
async function present(ctx: Object, document: Document, options: ?Options) {
|
||||||
options = {
|
options = {
|
||||||
includeCollaborators: true,
|
isPublic: false,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
ctx.cache.set(document.id, document);
|
ctx.cache.set(document.id, document);
|
||||||
@@ -27,39 +27,43 @@ async function present(ctx: Object, document: Document, options: ?Options) {
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
url: document.getUrl(),
|
url: document.getUrl(),
|
||||||
urlId: document.urlId,
|
urlId: document.urlId,
|
||||||
private: document.private,
|
|
||||||
title: document.title,
|
title: document.title,
|
||||||
text: document.text,
|
text: document.text,
|
||||||
emoji: document.emoji,
|
emoji: document.emoji,
|
||||||
createdAt: document.createdAt,
|
createdAt: document.createdAt,
|
||||||
createdBy: presentUser(ctx, document.createdBy),
|
createdBy: undefined,
|
||||||
updatedAt: document.updatedAt,
|
updatedAt: document.updatedAt,
|
||||||
updatedBy: presentUser(ctx, document.updatedBy),
|
updatedBy: undefined,
|
||||||
publishedAt: document.publishedAt,
|
publishedAt: document.publishedAt,
|
||||||
firstViewedAt: undefined,
|
firstViewedAt: undefined,
|
||||||
lastViewedAt: undefined,
|
lastViewedAt: undefined,
|
||||||
team: document.teamId,
|
team: document.teamId,
|
||||||
collaborators: [],
|
collaborators: [],
|
||||||
starred: !!(document.starred && document.starred.length),
|
starred: !!(document.starred && document.starred.length),
|
||||||
pinned: !!document.pinnedById,
|
|
||||||
revision: document.revisionCount,
|
revision: document.revisionCount,
|
||||||
collectionId: document.atlasId,
|
pinned: undefined,
|
||||||
|
collectionId: undefined,
|
||||||
collaboratorCount: undefined,
|
collaboratorCount: undefined,
|
||||||
collection: undefined,
|
collection: undefined,
|
||||||
views: undefined,
|
views: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.private && document.collection) {
|
if (!options.isPublic) {
|
||||||
data.collection = await presentCollection(ctx, document.collection);
|
data.pinned = !!document.pinnedById;
|
||||||
}
|
data.collectionId = document.atlasId;
|
||||||
|
data.createdBy = presentUser(ctx, document.createdBy);
|
||||||
|
data.updatedBy = presentUser(ctx, document.updatedBy);
|
||||||
|
|
||||||
if (document.views && document.views.length === 1) {
|
if (document.collection) {
|
||||||
data.views = document.views[0].count;
|
data.collection = await presentCollection(ctx, document.collection);
|
||||||
data.firstViewedAt = document.views[0].createdAt;
|
}
|
||||||
data.lastViewedAt = document.views[0].updatedAt;
|
|
||||||
}
|
if (document.views && document.views.length === 1) {
|
||||||
|
data.views = document.views[0].count;
|
||||||
|
data.firstViewedAt = document.views[0].createdAt;
|
||||||
|
data.lastViewedAt = document.views[0].updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.includeCollaborators) {
|
|
||||||
// This could be further optimized by using ctx.cache
|
// This could be further optimized by using ctx.cache
|
||||||
data.collaborators = await User.findAll({
|
data.collaborators = await User.findAll({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import presentDocument from './document';
|
|||||||
import presentRevision from './revision';
|
import presentRevision from './revision';
|
||||||
import presentCollection from './collection';
|
import presentCollection from './collection';
|
||||||
import presentApiKey from './apiKey';
|
import presentApiKey from './apiKey';
|
||||||
|
import presentShare from './share';
|
||||||
import presentTeam from './team';
|
import presentTeam from './team';
|
||||||
import presentIntegration from './integration';
|
import presentIntegration from './integration';
|
||||||
import presentSlackAttachment from './slackAttachment';
|
import presentSlackAttachment from './slackAttachment';
|
||||||
@@ -16,6 +17,7 @@ export {
|
|||||||
presentRevision,
|
presentRevision,
|
||||||
presentCollection,
|
presentCollection,
|
||||||
presentApiKey,
|
presentApiKey,
|
||||||
|
presentShare,
|
||||||
presentTeam,
|
presentTeam,
|
||||||
presentIntegration,
|
presentIntegration,
|
||||||
presentSlackAttachment,
|
presentSlackAttachment,
|
||||||
|
|||||||
17
server/presenters/share.js
Normal file
17
server/presenters/share.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// @flow
|
||||||
|
import { Share } from '../models';
|
||||||
|
import { presentUser } from '.';
|
||||||
|
|
||||||
|
function present(ctx: Object, share: Share) {
|
||||||
|
return {
|
||||||
|
id: share.id,
|
||||||
|
documentTitle: share.document.title,
|
||||||
|
documentUrl: share.document.getUrl(),
|
||||||
|
url: `${process.env.URL}/share/${share.id}`,
|
||||||
|
createdBy: presentUser(ctx, share.user),
|
||||||
|
createdAt: share.createdAt,
|
||||||
|
updatedAt: share.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default present;
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { Team, User } from '../models';
|
import { Share, Team, User } from '../models';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
|
export async function buildShare(overrides: Object = {}) {
|
||||||
|
if (!overrides.teamId) {
|
||||||
|
const team = await buildTeam();
|
||||||
|
overrides.teamId = team.id;
|
||||||
|
}
|
||||||
|
if (!overrides.userId) {
|
||||||
|
const user = await buildUser({ teamId: overrides.teamId });
|
||||||
|
overrides.userId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Share.create(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTeam(overrides: Object = {}) {
|
export function buildTeam(overrides: Object = {}) {
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user