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