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:
Tom Moor 2020-07-28 19:14:32 -07:00 committed by GitHub
parent 0b33b5bc05
commit 169ad5b025
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 514 additions and 77 deletions

View File

@ -95,13 +95,13 @@ const StyledEditor = styled(RichMarkdownEditor)`
p { p {
a { a {
color: ${props => props.theme.link}; color: ${props => props.theme.text};
border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)}; border-bottom: 1px solid ${props => lighten(0.5, props.theme.text)};
text-decoration: none !important; text-decoration: none !important;
font-weight: 500; font-weight: 500;
&:hover { &:hover {
border-bottom: 1px solid ${props => props.theme.link}; border-bottom: 1px solid ${props => props.theme.text};
text-decoration: none; text-decoration: none;
} }
} }

View File

@ -75,6 +75,7 @@ export const Outline = styled(Flex)`
export const LabelText = styled.div` export const LabelText = styled.div`
font-weight: 500; font-weight: 500;
padding-bottom: 4px; padding-bottom: 4px;
display: inline-block;
`; `;
export type Props = { export type Props = {

View File

@ -22,7 +22,7 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
return ( return (
<Label htmlFor={props.id}> <Label htmlFor={props.id}>
{component} {component}
<LabelText>&nbsp;{label}</LabelText> <LabelText>{label}</LabelText>
</Label> </Label>
); );
} }
@ -41,6 +41,7 @@ const Wrapper = styled.label`
width: ${props => props.width}px; width: ${props => props.width}px;
height: ${props => props.height}px; height: ${props => props.height}px;
margin-bottom: 4px; margin-bottom: 4px;
margin-right: 8px;
`; `;
const Slider = styled.span` const Slider = styled.span`

View File

@ -18,6 +18,10 @@ class Tooltip extends React.Component<Props> {
let content = tooltip; let content = tooltip;
if (!tooltip) {
return this.props.children;
}
if (shortcut) { if (shortcut) {
content = ( content = (
<React.Fragment> <React.Fragment>

View File

@ -110,8 +110,7 @@ class DocumentMenu extends React.Component<Props> {
handleShareLink = async (ev: SyntheticEvent<>) => { handleShareLink = async (ev: SyntheticEvent<>) => {
const { document } = this.props; const { document } = this.props;
if (!document.shareUrl) await document.share(); await document.share();
this.props.ui.setActiveModal("document-share", { document }); this.props.ui.setActiveModal("document-share", { document });
}; };

View File

@ -41,7 +41,6 @@ export default class Document extends BaseModel {
deletedAt: ?string; deletedAt: ?string;
url: string; url: string;
urlId: string; urlId: string;
shareUrl: ?string;
revision: number; revision: number;
get emoji() { get emoji() {
@ -90,10 +89,7 @@ export default class Document extends BaseModel {
@action @action
share = async () => { share = async () => {
const res = await client.post("/shares.create", { documentId: this.id }); return this.store.rootStore.shares.create({ documentId: this.id });
invariant(res && res.data, "Share data should be available");
this.shareUrl = res.data.url;
return this.shareUrl;
}; };
@action @action

View File

@ -17,6 +17,7 @@ class Event extends BaseModel {
name: string, name: string,
email: string, email: string,
title: string, title: string,
published: boolean,
}; };
get model() { get model() {

View File

@ -5,6 +5,8 @@ import User from "./User";
class Share extends BaseModel { class Share extends BaseModel {
id: string; id: string;
url: string; url: string;
published: boolean;
documentId: string;
documentTitle: string; documentTitle: string;
documentUrl: string; documentUrl: string;
createdBy: User; createdBy: User;

View File

@ -15,6 +15,7 @@ import HideSidebar from "./HideSidebar";
import Error404 from "scenes/Error404"; import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline"; import ErrorOffline from "scenes/ErrorOffline";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import SharesStore from "stores/SharesStore";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
import RevisionsStore from "stores/RevisionsStore"; import RevisionsStore from "stores/RevisionsStore";
import UiStore from "stores/UiStore"; import UiStore from "stores/UiStore";
@ -23,6 +24,7 @@ import { OfflineError } from "utils/errors";
type Props = {| type Props = {|
match: Object, match: Object,
location: Location, location: Location,
shares: SharesStore,
documents: DocumentsStore, documents: DocumentsStore,
policies: PoliciesStore, policies: PoliciesStore,
revisions: RevisionsStore, revisions: RevisionsStore,
@ -128,6 +130,8 @@ class DataLoader extends React.Component<Props> {
return this.goToDocumentCanonical(); return this.goToDocumentCanonical();
} }
this.props.shares.fetch(document.id);
const isMove = this.props.location.pathname.match(/move$/); const isMove = this.props.location.pathname.match(/move$/);
const canRedirect = !revisionId && !isMove && !shareId; const canRedirect = !revisionId && !isMove && !shareId;
if (canRedirect) { if (canRedirect) {
@ -187,5 +191,7 @@ class DataLoader extends React.Component<Props> {
} }
export default withRouter( export default withRouter(
inject("ui", "auth", "documents", "revisions", "policies")(DataLoader) inject("ui", "auth", "documents", "revisions", "policies", "shares")(
DataLoader
)
); );

View File

@ -6,7 +6,12 @@ import { observer, inject } from "mobx-react";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; 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 { transparentize, darken } from "polished";
import Document from "models/Document"; import Document from "models/Document";
import AuthStore from "stores/AuthStore"; import AuthStore from "stores/AuthStore";
@ -26,11 +31,13 @@ import Badge from "components/Badge";
import Collaborators from "components/Collaborators"; import Collaborators from "components/Collaborators";
import { Action, Separator } from "components/Actions"; import { Action, Separator } from "components/Actions";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
import SharesStore from "stores/SharesStore";
import UiStore from "stores/UiStore"; import UiStore from "stores/UiStore";
type Props = { type Props = {
auth: AuthStore, auth: AuthStore,
ui: UiStore, ui: UiStore,
shares: SharesStore,
policies: PoliciesStore, policies: PoliciesStore,
document: Document, document: Document,
isDraft: boolean, isDraft: boolean,
@ -82,9 +89,8 @@ class Header extends React.Component<Props> {
handleShareLink = async (ev: SyntheticEvent<>) => { handleShareLink = async (ev: SyntheticEvent<>) => {
const { document } = this.props; const { document } = this.props;
if (!document.shareUrl) { await document.share();
await document.share();
}
this.showShareModal = true; this.showShareModal = true;
}; };
@ -103,6 +109,7 @@ class Header extends React.Component<Props> {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />; if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { const {
shares,
document, document,
policies, policies,
isEditing, isEditing,
@ -116,6 +123,8 @@ class Header extends React.Component<Props> {
auth, auth,
} = this.props; } = this.props;
const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published;
const can = policies.abilities(document.id); const can = policies.abilities(document.id);
const canShareDocuments = auth.team && auth.team.sharing && can.share; const canShareDocuments = auth.team && auth.team.sharing && can.share;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds; const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
@ -181,22 +190,37 @@ class Header extends React.Component<Props> {
</Action> </Action>
)} )}
&nbsp; &nbsp;
<Collaborators <Fade>
document={document} <Collaborators
currentUserId={auth.user ? auth.user.id : undefined} document={document}
/> currentUserId={auth.user ? auth.user.id : undefined}
{!isDraft && />
!isEditing && </Fade>
{!isEditing &&
canShareDocuments && ( canShareDocuments && (
<Action> <Action>
<Button <Tooltip
onClick={this.handleShareLink} tooltip={
title="Share document" isPubliclyShared ? (
neutral <React.Fragment>
small 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> </Action>
)} )}
{isEditing && ( {isEditing && (
@ -367,4 +391,4 @@ const Title = styled.div`
`}; `};
`; `;
export default inject("auth", "ui", "policies")(Header); export default inject("auth", "ui", "policies", "shares")(Header);

View File

@ -1,28 +1,56 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import { observable } from "mobx"; 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 { Link } from "react-router-dom";
import Input from "components/Input"; import Input from "components/Input";
import Button from "components/Button"; import Button from "components/Button";
import Flex from "components/Flex";
import Switch from "components/Switch";
import CopyToClipboard from "components/CopyToClipboard"; import CopyToClipboard from "components/CopyToClipboard";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
import Document from "models/Document"; import Document from "models/Document";
import SharesStore from "stores/SharesStore";
import UiStore from "stores/UiStore";
import PoliciesStore from "stores/PoliciesStore";
type Props = { type Props = {
document: Document, document: Document,
shares: SharesStore,
ui: UiStore,
policies: PoliciesStore,
onSubmit: () => void, onSubmit: () => void,
}; };
@observer @observer
class DocumentShare extends React.Component<Props> { class DocumentShare extends React.Component<Props> {
@observable isCopied: boolean; @observable isCopied: boolean;
@observable isSaving: boolean = false;
timeout: TimeoutID; timeout: TimeoutID;
componentWillUnmount() { componentWillUnmount() {
clearTimeout(this.timeout); 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 = () => { handleCopied = () => {
this.isCopied = true; this.isCopied = true;
@ -33,35 +61,72 @@ class DocumentShare extends React.Component<Props> {
}; };
render() { 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 ( return (
<div> <div>
<HelpText> <HelpText>
The link below allows anyone in the world to access a read-only The link below provides a read-only version of the document{" "}
version of the document <strong>{document.title}</strong>. You can <strong>{document.title}</strong>.{" "}
revoke this link in settings at any time.{" "} {can.update &&
"You can optionally make it accessible to anyone with the link."}{" "}
<Link to="/settings/shares" onClick={onSubmit}> <Link to="/settings/shares" onClick={onSubmit}>
Manage share links Manage all share links
</Link>. </Link>.
</HelpText> </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 <Input
type="text" type="text"
label="Share link" label="Get link"
value={document.shareUrl || "Loading…"} value={share ? share.url : "Loading…"}
labelHidden
readOnly readOnly
/> />
<CopyToClipboard <CopyToClipboard
text={document.shareUrl || ""} text={share ? share.url : ""}
onCopy={this.handleCopied} onCopy={this.handleCopied}
> >
<Button type="submit" disabled={this.isCopied} primary> <Button type="submit" disabled={this.isCopied || !share} primary>
{this.isCopied ? "Copied!" : "Copy Link"} {this.isCopied ? "Copied!" : "Copy Link"}
</Button> </Button>
</CopyToClipboard> </CopyToClipboard>&nbsp;&nbsp;&nbsp;<a href={share.url} target="_blank">
Preview
</a>
</div> </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);

View File

@ -52,7 +52,7 @@ class Shares extends React.Component<Props> {
<Subheading>Shared Documents</Subheading> <Subheading>Shared Documents</Subheading>
{hasSharedDocuments ? ( {hasSharedDocuments ? (
<List> <List>
{shares.orderedData.map(share => ( {shares.published.map(share => (
<ShareListItem key={share.id} share={share} /> <ShareListItem key={share.id} share={share} />
))} ))}
</List> </List>

View File

@ -33,11 +33,27 @@ const description = event => {
return ( return (
<React.Fragment> <React.Fragment>
{capitalize(event.verbPastTense)} a{" "} {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>{" "} <Link to={`/doc/${event.documentId}`}>{event.data.name}</Link>{" "}
document document
</React.Fragment> </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": case "users.create":
return ( return (
<React.Fragment>{event.data.name} created an account</React.Fragment> <React.Fragment>{event.data.name} created an account</React.Fragment>

View File

@ -1,5 +1,6 @@
// @flow // @flow
import { sortBy } from "lodash"; import invariant from "invariant";
import { sortBy, filter, find } from "lodash";
import { action, computed } from "mobx"; import { action, computed } from "mobx";
import { client } from "utils/ApiClient"; import { client } from "utils/ApiClient";
import BaseStore from "./BaseStore"; import BaseStore from "./BaseStore";
@ -7,7 +8,7 @@ import RootStore from "./RootStore";
import Share from "models/Share"; import Share from "models/Share";
export default class SharesStore extends BaseStore<Share> { export default class SharesStore extends BaseStore<Share> {
actions = ["list", "create"]; actions = ["info", "list", "create", "update"];
constructor(rootStore: RootStore) { constructor(rootStore: RootStore) {
super(rootStore, Share); super(rootStore, Share);
@ -18,9 +19,44 @@ export default class SharesStore extends BaseStore<Share> {
return sortBy(Array.from(this.data.values()), "createdAt").reverse(); return sortBy(Array.from(this.data.values()), "createdAt").reverse();
} }
@computed
get published(): Share[] {
return filter(this.orderedData, share => share.published);
}
@action @action
revoke = async (share: Share) => { revoke = async (share: Share) => {
await client.post("/shares.revoke", { id: share.id }); await client.post("/shares.revoke", { id: share.id });
this.remove(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);
};
} }

View File

@ -121,7 +121,7 @@
"mobx-react": "^5.4.2", "mobx-react": "^5.4.2",
"natural-sort": "^1.0.0", "natural-sort": "^1.0.0",
"nodemailer": "^4.4.0", "nodemailer": "^4.4.0",
"outline-icons": "^1.19.0", "outline-icons": "^1.20.0",
"oy-vey": "^0.10.0", "oy-vey": "^0.10.0",
"pg": "^6.1.5", "pg": "^6.1.5",
"pg-hstore": "2.3.2", "pg-hstore": "2.3.2",

View File

@ -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`] = ` exports[`#shares.list should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "error": "authentication_required",
@ -26,3 +35,12 @@ Object {
"status": 401, "status": 401,
} }
`; `;
exports[`#shares.update should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@ -391,7 +391,12 @@ async function loadDocument({ id, shareId, user }) {
if (!share || share.document.archivedAt) { if (!share || share.document.archivedAt) {
throw new InvalidRequestError("Document could not be found for shareId"); throw new InvalidRequestError("Document could not be found for shareId");
} }
document = share.document; document = share.document;
if (!share.published) {
authorize(user, "read", document);
}
} else { } else {
document = await Document.findByPk( document = await Document.findByPk(
id, id,

View File

@ -3,8 +3,9 @@ import Router from "koa-router";
import Sequelize from "sequelize"; import Sequelize from "sequelize";
import auth from "../middlewares/authentication"; import auth from "../middlewares/authentication";
import pagination from "./middlewares/pagination"; import pagination from "./middlewares/pagination";
import { presentShare } from "../presenters"; import { presentShare, presentPolicies } from "../presenters";
import { Document, User, Event, Share, Team } from "../models"; import { Document, User, Event, Share, Team } from "../models";
import { NotFoundError } from "../errors";
import policy from "../policies"; import policy from "../policies";
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -12,15 +13,29 @@ const { authorize } = policy;
const router = new Router(); const router = new Router();
router.post("shares.info", auth(), async ctx => { router.post("shares.info", auth(), async ctx => {
const { id } = ctx.body; const { id, documentId } = ctx.body;
ctx.assertUuid(id, "id is required"); ctx.assertUuid(id || documentId, "id or documentId is required");
const user = ctx.state.user; 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); authorize(user, "read", share);
ctx.body = { ctx.body = {
data: presentShare(share), data: presentShare(share),
policies: presentPolicies(user, [share]),
}; };
}); });
@ -32,6 +47,7 @@ router.post("shares.list", auth(), pagination(), async ctx => {
const where = { const where = {
teamId: user.teamId, teamId: user.teamId,
userId: user.id, userId: user.id,
published: true,
revokedAt: { [Op.eq]: null }, revokedAt: { [Op.eq]: null },
}; };
@ -57,6 +73,11 @@ router.post("shares.list", auth(), pagination(), async ctx => {
required: true, required: true,
as: "user", as: "user",
}, },
{
model: Team,
required: true,
as: "team",
},
], ],
offset: ctx.state.pagination.offset, offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
@ -65,6 +86,35 @@ router.post("shares.list", auth(), pagination(), async ctx => {
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data: shares.map(presentShare), 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", document);
authorize(user, "share", team); authorize(user, "share", team);
const [share] = await Share.findOrCreate({ const [share, isCreated] = await Share.findOrCreate({
where: { where: {
documentId, documentId,
userId: user.id, userId: user.id,
@ -87,22 +137,26 @@ router.post("shares.create", auth(), async ctx => {
}, },
}); });
await Event.create({ if (isCreated) {
name: "shares.create", await Event.create({
documentId, name: "shares.create",
collectionId: document.collectionId, documentId,
modelId: share.id, collectionId: document.collectionId,
teamId: user.teamId, modelId: share.id,
actorId: user.id, teamId: user.teamId,
data: { name: document.title }, actorId: user.id,
ip: ctx.request.ip, data: { name: document.title },
}); ip: ctx.request.ip,
});
}
share.team = team;
share.user = user; share.user = user;
share.document = document; share.document = document;
ctx.body = { ctx.body = {
data: presentShare(share), data: presentShare(share),
policies: presentPolicies(user, [share]),
}; };
}); });

View File

@ -52,6 +52,24 @@ describe("#shares.list", async () => {
expect(body.data.length).toEqual(0); 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 () => { it("admins should return shares created by all users", async () => {
const { user, admin, document } = await seed(); const { user, admin, document } = await seed();
const share = await buildShare({ const share = await buildShare({
@ -108,6 +126,7 @@ describe("#shares.create", async () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.published).toBe(false);
expect(body.data.documentTitle).toBe(document.title); expect(body.data.documentTitle).toBe(document.title);
}); });
@ -129,6 +148,7 @@ describe("#shares.create", async () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.published).toBe(false);
expect(body.data.documentTitle).toBe(document.title); 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 () => { describe("#shares.revoke", async () => {
it("should allow author to revoke a share", async () => { it("should allow author to revoke a share", async () => {
const { user, document } = await seed(); const { user, document } = await seed();
@ -242,10 +413,15 @@ describe("#shares.revoke", async () => {
}); });
it("should require authorization", async () => { it("should require authorization", async () => {
const { document } = await seed(); const { admin, document } = await seed();
const user = await buildUser(); const user = await buildUser();
const res = await server.post("/api/shares.create", { const share = await buildShare({
body: { token: user.getJwtToken(), documentId: document.id }, 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); expect(res.status).toEqual(403);
}); });

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

View File

@ -80,6 +80,7 @@ Event.AUDIT_EVENTS = [
"documents.move", "documents.move",
"documents.delete", "documents.delete",
"shares.create", "shares.create",
"shares.update",
"shares.revoke", "shares.revoke",
"groups.create", "groups.create",
"groups.update", "groups.update",

View File

@ -9,6 +9,7 @@ const Share = sequelize.define(
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
}, },
published: DataTypes.BOOLEAN,
revokedAt: DataTypes.DATE, revokedAt: DataTypes.DATE,
revokedById: DataTypes.UUID, revokedById: DataTypes.UUID,
}, },
@ -30,10 +31,17 @@ Share.associate = models => {
as: "team", as: "team",
foreignKey: "teamId", foreignKey: "teamId",
}); });
Share.belongsTo(models.Document, { Share.belongsTo(models.Document.scope("withUnpublished"), {
as: "document", as: "document",
foreignKey: "documentId", foreignKey: "documentId",
}); });
Share.addScope("defaultScope", {
include: [
{ association: "user" },
{ association: "document" },
{ association: "team" },
],
});
}; };
Share.prototype.revoke = function(userId) { Share.prototype.revoke = function(userId) {

View File

@ -6,8 +6,7 @@ import { AdminRequiredError } from "../errors";
const { allow } = policy; const { allow } = policy;
allow(User, ["read"], Share, (user, share) => user.teamId === share.teamId); allow(User, ["read"], Share, (user, share) => user.teamId === share.teamId);
allow(User, ["update"], Share, (user, share) => false); allow(User, ["update", "revoke"], Share, (user, share) => {
allow(User, ["revoke"], Share, (user, share) => {
if (!share || user.teamId !== share.teamId) return false; if (!share || user.teamId !== share.teamId) return false;
if (user.id === share.userId) return true; if (user.id === share.userId) return true;
if (user.isAdmin) return true; if (user.isAdmin) return true;

View File

@ -5,9 +5,11 @@ import { presentUser } from ".";
export default function present(share: Share) { export default function present(share: Share) {
return { return {
id: share.id, id: share.id,
documentId: share.documentId,
documentTitle: share.document.title, documentTitle: share.document.title,
documentUrl: share.document.url, 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), createdBy: presentUser(share.user),
createdAt: share.createdAt, createdAt: share.createdAt,
updatedAt: share.updatedAt, updatedAt: share.updatedAt,

View File

@ -24,7 +24,10 @@ export async function buildShare(overrides: Object = {}) {
overrides.userId = user.id; overrides.userId = user.id;
} }
return Share.create(overrides); return Share.create({
published: true,
...overrides,
});
} }
export function buildTeam(overrides: Object = {}) { export function buildTeam(overrides: Object = {}) {

View File

@ -38,7 +38,7 @@ export default createGlobalStyle`
} }
a { a {
color: ${props => props.theme.primary}; color: ${props => props.theme.link};
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }

View File

@ -108,7 +108,7 @@ export const light = {
background: colors.white, background: colors.white,
secondaryBackground: colors.warmGrey, secondaryBackground: colors.warmGrey,
link: colors.almostBlack, link: colors.primary,
text: colors.almostBlack, text: colors.almostBlack,
textSecondary: colors.slateDark, textSecondary: colors.slateDark,
textTertiary: colors.slate, textTertiary: colors.slate,
@ -161,7 +161,7 @@ export const dark = {
background: colors.almostBlack, background: colors.almostBlack,
secondaryBackground: colors.black50, secondaryBackground: colors.black50,
link: colors.almostWhite, link: "#137FFB",
text: colors.almostWhite, text: colors.almostWhite,
textSecondary: lighten(0.1, colors.slate), textSecondary: lighten(0.1, colors.slate),
textTertiary: colors.slate, textTertiary: colors.slate,

View File

@ -7171,10 +7171,10 @@ osenv@^0.1.4:
os-homedir "^1.0.0" os-homedir "^1.0.0"
os-tmpdir "^1.0.0" os-tmpdir "^1.0.0"
outline-icons@^1.19.0, outline-icons@^1.19.1: outline-icons@^1.19.1, outline-icons@^1.20.0:
version "1.19.1" version "1.20.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.19.1.tgz#5251b966ae9987b18f060b58ce469d248c2313aa" resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.20.0.tgz#7d3814fade75ecd78492c9d9779183aed51502d8"
integrity sha512-YeGLDG7KgBvrDy+WOQUqN8rD3qO5u524E0Wj6ejaWNQ8/1ou6kkUrx65mk8LtESfKLMWZHuQG/u6onurY9rKIw== integrity sha512-YZJyqxl47zgHS3QAsznP18TBOgM4UdbeKoU6Am+UlgqUdXZi5VC1NbR/NwLBflgOs490DjF2EVTcRbYbN2ZMLg==
oy-vey@^0.10.0: oy-vey@^0.10.0:
version "0.10.0" version "0.10.0"