Merge pull request #651 from outline/share-links

Public share links
This commit is contained in:
Tom Moor
2018-05-26 13:33:38 -07:00
committed by GitHub
59 changed files with 1183 additions and 446 deletions

View File

@@ -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

View File

@@ -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 youre 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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 &&

View File

@@ -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> {

View File

@@ -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 && (

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
// @flow
import List from './List';
export default List;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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"

View File

@@ -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 ? (

View File

@@ -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,

View File

@@ -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

View File

@@ -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
View 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));

View File

@@ -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>
);
}
}

View File

@@ -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);
}

View File

@@ -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));

View 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;

View File

@@ -0,0 +1,3 @@
// @flow
import DocumentShare from './DocumentShare';
export default DocumentShare;

View File

@@ -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 youre looking for.</p>
<p>
Go to <a href="/">homepage</a>.
</p>
</CenteredContent>
);
};
export default Error404;

View File

@@ -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 were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
// Fetch more results

View 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);

View 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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View 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;

View 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;

View 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;

View File

@@ -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
View 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;

View File

@@ -30,7 +30,7 @@ class UiStore {
this.activeDocumentId = document.id;
if (document.publishedAt) {
this.activeCollectionId = document.collection.id;
this.activeCollectionId = document.collectionId;
}
};

View File

@@ -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

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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": {

View 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,
}
`;

View File

@@ -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 }),
};
});

View File

@@ -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 () => {

View File

@@ -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());

View File

@@ -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
View 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
View 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);
});
});

View File

@@ -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>
);
};

View 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
View 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;

View File

@@ -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,
};

View File

@@ -8,7 +8,7 @@ allow(User, 'create', Document);
allow(
User,
['read', 'update', 'delete'],
['read', 'update', 'delete', 'share'],
Document,
(user, document) => user.teamId === document.teamId
);

View File

@@ -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
View 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();
});

View File

@@ -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)
)
);
}

View File

@@ -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: {

View File

@@ -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,

View 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;

View File

@@ -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++;