feat: Sharing improvements (#1388)
* add migrations * first pass at API * feat: Updated share dialog UI * tests * test * styling tweaks * feat: Show share state on document * fix: Allow publishing share links for draft docs * test: shares.info
This commit is contained in:
parent
0b33b5bc05
commit
169ad5b025
@ -95,13 +95,13 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
|
||||
p {
|
||||
a {
|
||||
color: ${props => props.theme.link};
|
||||
border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)};
|
||||
color: ${props => props.theme.text};
|
||||
border-bottom: 1px solid ${props => lighten(0.5, props.theme.text)};
|
||||
text-decoration: none !important;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid ${props => props.theme.link};
|
||||
border-bottom: 1px solid ${props => props.theme.text};
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
@ -75,6 +75,7 @@ export const Outline = styled(Flex)`
|
||||
export const LabelText = styled.div`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
|
@ -22,7 +22,7 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
return (
|
||||
<Label htmlFor={props.id}>
|
||||
{component}
|
||||
<LabelText> {label}</LabelText>
|
||||
<LabelText>{label}</LabelText>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
@ -41,6 +41,7 @@ const Wrapper = styled.label`
|
||||
width: ${props => props.width}px;
|
||||
height: ${props => props.height}px;
|
||||
margin-bottom: 4px;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const Slider = styled.span`
|
||||
|
@ -18,6 +18,10 @@ class Tooltip extends React.Component<Props> {
|
||||
|
||||
let content = tooltip;
|
||||
|
||||
if (!tooltip) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
if (shortcut) {
|
||||
content = (
|
||||
<React.Fragment>
|
||||
|
@ -110,8 +110,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
|
||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
if (!document.shareUrl) await document.share();
|
||||
|
||||
await document.share();
|
||||
this.props.ui.setActiveModal("document-share", { document });
|
||||
};
|
||||
|
||||
|
@ -41,7 +41,6 @@ export default class Document extends BaseModel {
|
||||
deletedAt: ?string;
|
||||
url: string;
|
||||
urlId: string;
|
||||
shareUrl: ?string;
|
||||
revision: number;
|
||||
|
||||
get emoji() {
|
||||
@ -90,10 +89,7 @@ export default class Document extends BaseModel {
|
||||
|
||||
@action
|
||||
share = async () => {
|
||||
const res = await client.post("/shares.create", { documentId: this.id });
|
||||
invariant(res && res.data, "Share data should be available");
|
||||
this.shareUrl = res.data.url;
|
||||
return this.shareUrl;
|
||||
return this.store.rootStore.shares.create({ documentId: this.id });
|
||||
};
|
||||
|
||||
@action
|
||||
|
@ -17,6 +17,7 @@ class Event extends BaseModel {
|
||||
name: string,
|
||||
email: string,
|
||||
title: string,
|
||||
published: boolean,
|
||||
};
|
||||
|
||||
get model() {
|
||||
|
@ -5,6 +5,8 @@ import User from "./User";
|
||||
class Share extends BaseModel {
|
||||
id: string;
|
||||
url: string;
|
||||
published: boolean;
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
documentUrl: string;
|
||||
createdBy: User;
|
||||
|
@ -15,6 +15,7 @@ import HideSidebar from "./HideSidebar";
|
||||
import Error404 from "scenes/Error404";
|
||||
import ErrorOffline from "scenes/ErrorOffline";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import SharesStore from "stores/SharesStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import RevisionsStore from "stores/RevisionsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
@ -23,6 +24,7 @@ import { OfflineError } from "utils/errors";
|
||||
type Props = {|
|
||||
match: Object,
|
||||
location: Location,
|
||||
shares: SharesStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
revisions: RevisionsStore,
|
||||
@ -128,6 +130,8 @@ class DataLoader extends React.Component<Props> {
|
||||
return this.goToDocumentCanonical();
|
||||
}
|
||||
|
||||
this.props.shares.fetch(document.id);
|
||||
|
||||
const isMove = this.props.location.pathname.match(/move$/);
|
||||
const canRedirect = !revisionId && !isMove && !shareId;
|
||||
if (canRedirect) {
|
||||
@ -187,5 +191,7 @@ class DataLoader extends React.Component<Props> {
|
||||
}
|
||||
|
||||
export default withRouter(
|
||||
inject("ui", "auth", "documents", "revisions", "policies")(DataLoader)
|
||||
inject("ui", "auth", "documents", "revisions", "policies", "shares")(
|
||||
DataLoader
|
||||
)
|
||||
);
|
||||
|
@ -6,7 +6,12 @@ import { observer, inject } from "mobx-react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { TableOfContentsIcon, EditIcon, PlusIcon } from "outline-icons";
|
||||
import {
|
||||
TableOfContentsIcon,
|
||||
EditIcon,
|
||||
GlobeIcon,
|
||||
PlusIcon,
|
||||
} from "outline-icons";
|
||||
import { transparentize, darken } from "polished";
|
||||
import Document from "models/Document";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
@ -26,11 +31,13 @@ import Badge from "components/Badge";
|
||||
import Collaborators from "components/Collaborators";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import SharesStore from "stores/SharesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
shares: SharesStore,
|
||||
policies: PoliciesStore,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
@ -82,9 +89,8 @@ class Header extends React.Component<Props> {
|
||||
|
||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
if (!document.shareUrl) {
|
||||
await document.share();
|
||||
}
|
||||
await document.share();
|
||||
|
||||
this.showShareModal = true;
|
||||
};
|
||||
|
||||
@ -103,6 +109,7 @@ class Header extends React.Component<Props> {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const {
|
||||
shares,
|
||||
document,
|
||||
policies,
|
||||
isEditing,
|
||||
@ -116,6 +123,8 @@ class Header extends React.Component<Props> {
|
||||
auth,
|
||||
} = this.props;
|
||||
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const isPubliclyShared = share && share.published;
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = auth.team && auth.team.sharing && can.share;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
@ -181,22 +190,37 @@ class Header extends React.Component<Props> {
|
||||
</Action>
|
||||
)}
|
||||
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
{!isDraft &&
|
||||
!isEditing &&
|
||||
<Fade>
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
</Fade>
|
||||
{!isEditing &&
|
||||
canShareDocuments && (
|
||||
<Action>
|
||||
<Button
|
||||
onClick={this.handleShareLink}
|
||||
title="Share document"
|
||||
neutral
|
||||
small
|
||||
<Tooltip
|
||||
tooltip={
|
||||
isPubliclyShared ? (
|
||||
<React.Fragment>
|
||||
Anyone with the link <br />can view this document
|
||||
</React.Fragment>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
<Button
|
||||
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
|
||||
onClick={this.handleShareLink}
|
||||
neutral
|
||||
small
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{isEditing && (
|
||||
@ -367,4 +391,4 @@ const Title = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject("auth", "ui", "policies")(Header);
|
||||
export default inject("auth", "ui", "policies", "shares")(Header);
|
||||
|
@ -1,28 +1,56 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { GlobeIcon, PadlockIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import invariant from "invariant";
|
||||
import { Link } from "react-router-dom";
|
||||
import Input from "components/Input";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import Switch from "components/Switch";
|
||||
import CopyToClipboard from "components/CopyToClipboard";
|
||||
import HelpText from "components/HelpText";
|
||||
import Document from "models/Document";
|
||||
import SharesStore from "stores/SharesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
shares: SharesStore,
|
||||
ui: UiStore,
|
||||
policies: PoliciesStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentShare extends React.Component<Props> {
|
||||
@observable isCopied: boolean;
|
||||
@observable isSaving: boolean = false;
|
||||
timeout: TimeoutID;
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
handlePublishedChange = async event => {
|
||||
const { document, shares } = this.props;
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
invariant(share, "Share must exist");
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
await share.save({ published: event.target.checked });
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
handleCopied = () => {
|
||||
this.isCopied = true;
|
||||
|
||||
@ -33,35 +61,72 @@ class DocumentShare extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { document, onSubmit } = this.props;
|
||||
const { document, policies, shares, onSubmit } = this.props;
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const can = policies.abilities(share ? share.id : "");
|
||||
|
||||
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.{" "}
|
||||
The link below provides a read-only version of the document{" "}
|
||||
<strong>{document.title}</strong>.{" "}
|
||||
{can.update &&
|
||||
"You can optionally make it accessible to anyone with the link."}{" "}
|
||||
<Link to="/settings/shares" onClick={onSubmit}>
|
||||
Manage share links
|
||||
Manage all share links
|
||||
</Link>.
|
||||
</HelpText>
|
||||
{can.update && (
|
||||
<React.Fragment>
|
||||
<Switch
|
||||
id="published"
|
||||
label="Publish to internet"
|
||||
onChange={this.handlePublishedChange}
|
||||
checked={share ? share.published : false}
|
||||
disabled={!share || this.isSaving}
|
||||
/>
|
||||
<Privacy>
|
||||
{share.published ? <GlobeIcon /> : <PadlockIcon />}
|
||||
<PrivacyText>
|
||||
{share.published
|
||||
? "Anyone with the link can view this document"
|
||||
: "Only team members with access can view this document"}
|
||||
</PrivacyText>
|
||||
</Privacy>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<br />
|
||||
<Input
|
||||
type="text"
|
||||
label="Share link"
|
||||
value={document.shareUrl || "Loading…"}
|
||||
label="Get link"
|
||||
value={share ? share.url : "Loading…"}
|
||||
labelHidden
|
||||
readOnly
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={document.shareUrl || ""}
|
||||
text={share ? share.url : ""}
|
||||
onCopy={this.handleCopied}
|
||||
>
|
||||
<Button type="submit" disabled={this.isCopied} primary>
|
||||
<Button type="submit" disabled={this.isCopied || !share} primary>
|
||||
{this.isCopied ? "Copied!" : "Copy Link"}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</CopyToClipboard> <a href={share.url} target="_blank">
|
||||
Preview
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentShare;
|
||||
const Privacy = styled(Flex)`
|
||||
flex-align: center;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
const PrivacyText = styled(HelpText)`
|
||||
margin: 0;
|
||||
margin-left: 2px;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default inject("shares", "ui", "policies")(DocumentShare);
|
||||
|
@ -52,7 +52,7 @@ class Shares extends React.Component<Props> {
|
||||
<Subheading>Shared Documents</Subheading>
|
||||
{hasSharedDocuments ? (
|
||||
<List>
|
||||
{shares.orderedData.map(share => (
|
||||
{shares.published.map(share => (
|
||||
<ShareListItem key={share.id} share={share} />
|
||||
))}
|
||||
</List>
|
||||
|
@ -33,11 +33,27 @@ const description = event => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{capitalize(event.verbPastTense)} a{" "}
|
||||
<Link to={`/share/${event.modelId || ""}`}>public link</Link> to the{" "}
|
||||
<Link to={`/share/${event.modelId || ""}`}>share link</Link> to the{" "}
|
||||
<Link to={`/doc/${event.documentId}`}>{event.data.name}</Link>{" "}
|
||||
document
|
||||
</React.Fragment>
|
||||
);
|
||||
case "shares.update":
|
||||
return (
|
||||
<React.Fragment>
|
||||
{event.data.published ? (
|
||||
<React.Fragment>
|
||||
Published a document{" "}
|
||||
<Link to={`/share/${event.modelId || ""}`}>share link</Link>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
Unpublished a document{" "}
|
||||
<Link to={`/share/${event.modelId || ""}`}>share link</Link>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
case "users.create":
|
||||
return (
|
||||
<React.Fragment>{event.data.name} created an account</React.Fragment>
|
||||
|
@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import { sortBy } from "lodash";
|
||||
import invariant from "invariant";
|
||||
import { sortBy, filter, find } from "lodash";
|
||||
import { action, computed } from "mobx";
|
||||
import { client } from "utils/ApiClient";
|
||||
import BaseStore from "./BaseStore";
|
||||
@ -7,7 +8,7 @@ import RootStore from "./RootStore";
|
||||
import Share from "models/Share";
|
||||
|
||||
export default class SharesStore extends BaseStore<Share> {
|
||||
actions = ["list", "create"];
|
||||
actions = ["info", "list", "create", "update"];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Share);
|
||||
@ -18,9 +19,44 @@ export default class SharesStore extends BaseStore<Share> {
|
||||
return sortBy(Array.from(this.data.values()), "createdAt").reverse();
|
||||
}
|
||||
|
||||
@computed
|
||||
get published(): Share[] {
|
||||
return filter(this.orderedData, share => share.published);
|
||||
}
|
||||
|
||||
@action
|
||||
revoke = async (share: Share) => {
|
||||
await client.post("/shares.revoke", { id: share.id });
|
||||
this.remove(share.id);
|
||||
};
|
||||
|
||||
@action
|
||||
async create(params: Object) {
|
||||
let item = this.getByDocumentId(params.documentId);
|
||||
if (item) return item;
|
||||
|
||||
return super.create(params);
|
||||
}
|
||||
|
||||
@action
|
||||
async fetch(documentId: string, options?: Object = {}): Promise<*> {
|
||||
let item = this.getByDocumentId(documentId);
|
||||
if (item && !options.force) return item;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.modelName}s.info`, { documentId });
|
||||
invariant(res && res.data, "Data should be available");
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(res.data);
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
getByDocumentId = (documentId): ?Share => {
|
||||
return find(this.orderedData, share => share.documentId === documentId);
|
||||
};
|
||||
}
|
||||
|
@ -121,7 +121,7 @@
|
||||
"mobx-react": "^5.4.2",
|
||||
"natural-sort": "^1.0.0",
|
||||
"nodemailer": "^4.4.0",
|
||||
"outline-icons": "^1.19.0",
|
||||
"outline-icons": "^1.20.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
"pg": "^6.1.5",
|
||||
"pg-hstore": "2.3.2",
|
||||
|
@ -9,6 +9,15 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#shares.info 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",
|
||||
@ -26,3 +35,12 @@ Object {
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#shares.update should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
@ -391,7 +391,12 @@ async function loadDocument({ id, shareId, user }) {
|
||||
if (!share || share.document.archivedAt) {
|
||||
throw new InvalidRequestError("Document could not be found for shareId");
|
||||
}
|
||||
|
||||
document = share.document;
|
||||
|
||||
if (!share.published) {
|
||||
authorize(user, "read", document);
|
||||
}
|
||||
} else {
|
||||
document = await Document.findByPk(
|
||||
id,
|
||||
|
@ -3,8 +3,9 @@ import Router from "koa-router";
|
||||
import Sequelize from "sequelize";
|
||||
import auth from "../middlewares/authentication";
|
||||
import pagination from "./middlewares/pagination";
|
||||
import { presentShare } from "../presenters";
|
||||
import { presentShare, presentPolicies } from "../presenters";
|
||||
import { Document, User, Event, Share, Team } from "../models";
|
||||
import { NotFoundError } from "../errors";
|
||||
import policy from "../policies";
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
@ -12,15 +13,29 @@ const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("shares.info", auth(), async ctx => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
const { id, documentId } = ctx.body;
|
||||
ctx.assertUuid(id || documentId, "id or documentId is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const share = await Share.findByPk(id);
|
||||
const share = await Share.findOne({
|
||||
where: id
|
||||
? {
|
||||
id,
|
||||
}
|
||||
: {
|
||||
documentId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (!share) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
authorize(user, "read", share);
|
||||
|
||||
ctx.body = {
|
||||
data: presentShare(share),
|
||||
policies: presentPolicies(user, [share]),
|
||||
};
|
||||
});
|
||||
|
||||
@ -32,6 +47,7 @@ router.post("shares.list", auth(), pagination(), async ctx => {
|
||||
const where = {
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
published: true,
|
||||
revokedAt: { [Op.eq]: null },
|
||||
};
|
||||
|
||||
@ -57,6 +73,11 @@ router.post("shares.list", auth(), pagination(), async ctx => {
|
||||
required: true,
|
||||
as: "user",
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
required: true,
|
||||
as: "team",
|
||||
},
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
@ -65,6 +86,35 @@ router.post("shares.list", auth(), pagination(), async ctx => {
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: shares.map(presentShare),
|
||||
policies: presentPolicies(user, shares),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("shares.update", auth(), async ctx => {
|
||||
const { id, published } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertPresent(published, "published is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const share = await Share.findByPk(id);
|
||||
authorize(user, "update", share);
|
||||
|
||||
share.published = published;
|
||||
await share.save();
|
||||
|
||||
await Event.create({
|
||||
name: "shares.update",
|
||||
documentId: share.documentId,
|
||||
modelId: share.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { published },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentShare(share),
|
||||
policies: presentPolicies(user, [share]),
|
||||
};
|
||||
});
|
||||
|
||||
@ -78,7 +128,7 @@ router.post("shares.create", auth(), async ctx => {
|
||||
authorize(user, "share", document);
|
||||
authorize(user, "share", team);
|
||||
|
||||
const [share] = await Share.findOrCreate({
|
||||
const [share, isCreated] = await Share.findOrCreate({
|
||||
where: {
|
||||
documentId,
|
||||
userId: user.id,
|
||||
@ -87,22 +137,26 @@ router.post("shares.create", auth(), async ctx => {
|
||||
},
|
||||
});
|
||||
|
||||
await Event.create({
|
||||
name: "shares.create",
|
||||
documentId,
|
||||
collectionId: document.collectionId,
|
||||
modelId: share.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { name: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
if (isCreated) {
|
||||
await Event.create({
|
||||
name: "shares.create",
|
||||
documentId,
|
||||
collectionId: document.collectionId,
|
||||
modelId: share.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { name: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
share.team = team;
|
||||
share.user = user;
|
||||
share.document = document;
|
||||
|
||||
ctx.body = {
|
||||
data: presentShare(share),
|
||||
policies: presentPolicies(user, [share]),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -52,6 +52,24 @@ describe("#shares.list", async () => {
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not return unpublished shares", async () => {
|
||||
const { user, document } = await seed();
|
||||
await buildShare({
|
||||
published: false,
|
||||
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(0);
|
||||
});
|
||||
|
||||
it("admins should return shares created by all users", async () => {
|
||||
const { user, admin, document } = await seed();
|
||||
const share = await buildShare({
|
||||
@ -108,6 +126,7 @@ describe("#shares.create", async () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.published).toBe(false);
|
||||
expect(body.data.documentTitle).toBe(document.title);
|
||||
});
|
||||
|
||||
@ -129,6 +148,7 @@ describe("#shares.create", async () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.published).toBe(false);
|
||||
expect(body.data.documentTitle).toBe(document.title);
|
||||
});
|
||||
|
||||
@ -196,6 +216,157 @@ describe("#shares.create", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.info", async () => {
|
||||
it("should allow reading share by id", 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.info", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
});
|
||||
|
||||
it("should allow reading share by documentId", 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.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should not find share for different user", async () => {
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
|
||||
it("should require authentication", 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.info", {
|
||||
body: { id: share.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.update", async () => {
|
||||
it("should allow author to update a share", 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.update", {
|
||||
body: { token: user.getJwtToken(), id: share.id, published: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow admin to update a share", async () => {
|
||||
const { user, admin, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: { token: admin.getJwtToken(), id: share.id, published: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should require authentication", 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.update", {
|
||||
body: { id: share.id, published: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: { token: user.getJwtToken(), id: share.id, published: true },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.revoke", async () => {
|
||||
it("should allow author to revoke a share", async () => {
|
||||
const { user, document } = await seed();
|
||||
@ -242,10 +413,15 @@ describe("#shares.revoke", async () => {
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { document } = await seed();
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.revoke", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
20
server/migrations/20200723055414-add-published-to-shares.js
Normal file
20
server/migrations/20200723055414-add-published-to-shares.js
Normal file
@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('shares', 'published', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
});
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
update shares
|
||||
set "published" = true
|
||||
`);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('shares', 'published');
|
||||
}
|
||||
};
|
@ -80,6 +80,7 @@ Event.AUDIT_EVENTS = [
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"shares.create",
|
||||
"shares.update",
|
||||
"shares.revoke",
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
|
@ -9,6 +9,7 @@ const Share = sequelize.define(
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
published: DataTypes.BOOLEAN,
|
||||
revokedAt: DataTypes.DATE,
|
||||
revokedById: DataTypes.UUID,
|
||||
},
|
||||
@ -30,10 +31,17 @@ Share.associate = models => {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
Share.belongsTo(models.Document, {
|
||||
Share.belongsTo(models.Document.scope("withUnpublished"), {
|
||||
as: "document",
|
||||
foreignKey: "documentId",
|
||||
});
|
||||
Share.addScope("defaultScope", {
|
||||
include: [
|
||||
{ association: "user" },
|
||||
{ association: "document" },
|
||||
{ association: "team" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
Share.prototype.revoke = function(userId) {
|
||||
|
@ -6,8 +6,7 @@ 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, ["revoke"], Share, (user, share) => {
|
||||
allow(User, ["update", "revoke"], Share, (user, share) => {
|
||||
if (!share || user.teamId !== share.teamId) return false;
|
||||
if (user.id === share.userId) return true;
|
||||
if (user.isAdmin) return true;
|
||||
|
@ -5,9 +5,11 @@ import { presentUser } from ".";
|
||||
export default function present(share: Share) {
|
||||
return {
|
||||
id: share.id,
|
||||
documentId: share.documentId,
|
||||
documentTitle: share.document.title,
|
||||
documentUrl: share.document.url,
|
||||
url: `${process.env.URL}/share/${share.id}`,
|
||||
published: share.published,
|
||||
url: `${share.team.url}/share/${share.id}`,
|
||||
createdBy: presentUser(share.user),
|
||||
createdAt: share.createdAt,
|
||||
updatedAt: share.updatedAt,
|
||||
|
@ -24,7 +24,10 @@ export async function buildShare(overrides: Object = {}) {
|
||||
overrides.userId = user.id;
|
||||
}
|
||||
|
||||
return Share.create(overrides);
|
||||
return Share.create({
|
||||
published: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTeam(overrides: Object = {}) {
|
||||
|
@ -38,7 +38,7 @@ export default createGlobalStyle`
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${props => props.theme.primary};
|
||||
color: ${props => props.theme.link};
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -108,7 +108,7 @@ export const light = {
|
||||
background: colors.white,
|
||||
secondaryBackground: colors.warmGrey,
|
||||
|
||||
link: colors.almostBlack,
|
||||
link: colors.primary,
|
||||
text: colors.almostBlack,
|
||||
textSecondary: colors.slateDark,
|
||||
textTertiary: colors.slate,
|
||||
@ -161,7 +161,7 @@ export const dark = {
|
||||
background: colors.almostBlack,
|
||||
secondaryBackground: colors.black50,
|
||||
|
||||
link: colors.almostWhite,
|
||||
link: "#137FFB",
|
||||
text: colors.almostWhite,
|
||||
textSecondary: lighten(0.1, colors.slate),
|
||||
textTertiary: colors.slate,
|
||||
|
@ -7171,10 +7171,10 @@ osenv@^0.1.4:
|
||||
os-homedir "^1.0.0"
|
||||
os-tmpdir "^1.0.0"
|
||||
|
||||
outline-icons@^1.19.0, outline-icons@^1.19.1:
|
||||
version "1.19.1"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.19.1.tgz#5251b966ae9987b18f060b58ce469d248c2313aa"
|
||||
integrity sha512-YeGLDG7KgBvrDy+WOQUqN8rD3qO5u524E0Wj6ejaWNQ8/1ou6kkUrx65mk8LtESfKLMWZHuQG/u6onurY9rKIw==
|
||||
outline-icons@^1.19.1, outline-icons@^1.20.0:
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.20.0.tgz#7d3814fade75ecd78492c9d9779183aed51502d8"
|
||||
integrity sha512-YZJyqxl47zgHS3QAsznP18TBOgM4UdbeKoU6Am+UlgqUdXZi5VC1NbR/NwLBflgOs490DjF2EVTcRbYbN2ZMLg==
|
||||
|
||||
oy-vey@^0.10.0:
|
||||
version "0.10.0"
|
||||
|
Reference in New Issue
Block a user