diff --git a/app/components/Editor.js b/app/components/Editor.js
index cc92f784..f8cc8d23 100644
--- a/app/components/Editor.js
+++ b/app/components/Editor.js
@@ -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;
}
}
diff --git a/app/components/Input.js b/app/components/Input.js
index 18ea9be9..566e7a0d 100644
--- a/app/components/Input.js
+++ b/app/components/Input.js
@@ -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 = {
diff --git a/app/components/Switch.js b/app/components/Switch.js
index 43a6cf42..e8aad992 100644
--- a/app/components/Switch.js
+++ b/app/components/Switch.js
@@ -22,7 +22,7 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
return (
{component}
- {label}
+ {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`
diff --git a/app/components/Tooltip.js b/app/components/Tooltip.js
index dbafffa8..57784ceb 100644
--- a/app/components/Tooltip.js
+++ b/app/components/Tooltip.js
@@ -18,6 +18,10 @@ class Tooltip extends React.Component {
let content = tooltip;
+ if (!tooltip) {
+ return this.props.children;
+ }
+
if (shortcut) {
content = (
diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js
index cda74fc0..cea44ba5 100644
--- a/app/menus/DocumentMenu.js
+++ b/app/menus/DocumentMenu.js
@@ -110,8 +110,7 @@ class DocumentMenu extends React.Component {
handleShareLink = async (ev: SyntheticEvent<>) => {
const { document } = this.props;
- if (!document.shareUrl) await document.share();
-
+ await document.share();
this.props.ui.setActiveModal("document-share", { document });
};
diff --git a/app/models/Document.js b/app/models/Document.js
index e4619316..3fad9766 100644
--- a/app/models/Document.js
+++ b/app/models/Document.js
@@ -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
diff --git a/app/models/Event.js b/app/models/Event.js
index b2f175d2..8366a801 100644
--- a/app/models/Event.js
+++ b/app/models/Event.js
@@ -17,6 +17,7 @@ class Event extends BaseModel {
name: string,
email: string,
title: string,
+ published: boolean,
};
get model() {
diff --git a/app/models/Share.js b/app/models/Share.js
index b3902d71..3c530555 100644
--- a/app/models/Share.js
+++ b/app/models/Share.js
@@ -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;
diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js
index 5cb93ce4..8d65c41d 100644
--- a/app/scenes/Document/components/DataLoader.js
+++ b/app/scenes/Document/components/DataLoader.js
@@ -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 {
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 {
}
export default withRouter(
- inject("ui", "auth", "documents", "revisions", "policies")(DataLoader)
+ inject("ui", "auth", "documents", "revisions", "policies", "shares")(
+ DataLoader
+ )
);
diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js
index 1b8d84ab..2a20c9fc 100644
--- a/app/scenes/Document/components/Header.js
+++ b/app/scenes/Document/components/Header.js
@@ -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 {
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 {
if (this.redirectTo) return ;
const {
+ shares,
document,
policies,
isEditing,
@@ -116,6 +123,8 @@ class Header extends React.Component {
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 {
)}
-
- {!isDraft &&
- !isEditing &&
+
+
+
+ {!isEditing &&
canShareDocuments && (
-
+ Anyone with the link can view this document
+
+ ) : (
+ ""
+ )
+ }
+ delay={500}
+ placement="bottom"
>
- Share
-
+ : undefined}
+ onClick={this.handleShareLink}
+ neutral
+ small
+ >
+ Share
+
+
)}
{isEditing && (
@@ -367,4 +391,4 @@ const Title = styled.div`
`};
`;
-export default inject("auth", "ui", "policies")(Header);
+export default inject("auth", "ui", "policies", "shares")(Header);
diff --git a/app/scenes/DocumentShare.js b/app/scenes/DocumentShare.js
index a79c753e..e7723288 100644
--- a/app/scenes/DocumentShare.js
+++ b/app/scenes/DocumentShare.js
@@ -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 {
@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 {
};
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 (
- The link below allows anyone in the world to access a read-only
- version of the document {document.title} . You can
- revoke this link in settings at any time.{" "}
+ The link below provides a read-only version of the document{" "}
+ {document.title} .{" "}
+ {can.update &&
+ "You can optionally make it accessible to anyone with the link."}{" "}
- Manage share links
+ Manage all share links
.
+ {can.update && (
+
+
+
+ {share.published ? : }
+
+ {share.published
+ ? "Anyone with the link can view this document"
+ : "Only team members with access can view this document"}
+
+
+
+ )}
+
-
+
{this.isCopied ? "Copied!" : "Copy Link"}
-
+
+ Preview
+
);
}
}
-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);
diff --git a/app/scenes/Settings/Shares.js b/app/scenes/Settings/Shares.js
index a164dde3..5f1371fd 100644
--- a/app/scenes/Settings/Shares.js
+++ b/app/scenes/Settings/Shares.js
@@ -52,7 +52,7 @@ class Shares extends React.Component {
Shared Documents
{hasSharedDocuments ? (
- {shares.orderedData.map(share => (
+ {shares.published.map(share => (
))}
diff --git a/app/scenes/Settings/components/EventListItem.js b/app/scenes/Settings/components/EventListItem.js
index 068e3fde..ceed25e7 100644
--- a/app/scenes/Settings/components/EventListItem.js
+++ b/app/scenes/Settings/components/EventListItem.js
@@ -33,11 +33,27 @@ const description = event => {
return (
{capitalize(event.verbPastTense)} a{" "}
- public link to the{" "}
+ share link to the{" "}
{event.data.name}{" "}
document
);
+ case "shares.update":
+ return (
+
+ {event.data.published ? (
+
+ Published a document{" "}
+ share link
+
+ ) : (
+
+ Unpublished a document{" "}
+ share link
+
+ )}
+
+ );
case "users.create":
return (
{event.data.name} created an account
diff --git a/app/stores/SharesStore.js b/app/stores/SharesStore.js
index 133a392d..57ea16db 100644
--- a/app/stores/SharesStore.js
+++ b/app/stores/SharesStore.js
@@ -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 {
- actions = ["list", "create"];
+ actions = ["info", "list", "create", "update"];
constructor(rootStore: RootStore) {
super(rootStore, Share);
@@ -18,9 +19,44 @@ export default class SharesStore extends BaseStore {
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);
+ };
}
diff --git a/package.json b/package.json
index 0ddc2dc6..2fd888b3 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/api/__snapshots__/shares.test.js.snap b/server/api/__snapshots__/shares.test.js.snap
index 439b96bb..d0f68fd9 100644
--- a/server/api/__snapshots__/shares.test.js.snap
+++ b/server/api/__snapshots__/shares.test.js.snap
@@ -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,
+}
+`;
diff --git a/server/api/documents.js b/server/api/documents.js
index 9289fee4..c1cd4e7d 100644
--- a/server/api/documents.js
+++ b/server/api/documents.js
@@ -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,
diff --git a/server/api/shares.js b/server/api/shares.js
index 2ed07810..0edcbf73 100644
--- a/server/api/shares.js
+++ b/server/api/shares.js
@@ -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]),
};
});
diff --git a/server/api/shares.test.js b/server/api/shares.test.js
index 4dd501ba..beec01c6 100644
--- a/server/api/shares.test.js
+++ b/server/api/shares.test.js
@@ -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);
});
diff --git a/server/migrations/20200723055414-add-published-to-shares.js b/server/migrations/20200723055414-add-published-to-shares.js
new file mode 100644
index 00000000..82355125
--- /dev/null
+++ b/server/migrations/20200723055414-add-published-to-shares.js
@@ -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');
+ }
+};
\ No newline at end of file
diff --git a/server/models/Event.js b/server/models/Event.js
index 8d7df44f..73f7e3b6 100644
--- a/server/models/Event.js
+++ b/server/models/Event.js
@@ -80,6 +80,7 @@ Event.AUDIT_EVENTS = [
"documents.move",
"documents.delete",
"shares.create",
+ "shares.update",
"shares.revoke",
"groups.create",
"groups.update",
diff --git a/server/models/Share.js b/server/models/Share.js
index f76a2a12..47edf755 100644
--- a/server/models/Share.js
+++ b/server/models/Share.js
@@ -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) {
diff --git a/server/policies/share.js b/server/policies/share.js
index 19c82ea1..6455dd4e 100644
--- a/server/policies/share.js
+++ b/server/policies/share.js
@@ -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;
diff --git a/server/presenters/share.js b/server/presenters/share.js
index 4d9d1c0f..a33000e0 100644
--- a/server/presenters/share.js
+++ b/server/presenters/share.js
@@ -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,
diff --git a/server/test/factories.js b/server/test/factories.js
index 2709adb4..0f860d4f 100644
--- a/server/test/factories.js
+++ b/server/test/factories.js
@@ -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 = {}) {
diff --git a/shared/styles/globals.js b/shared/styles/globals.js
index fd43cfa7..a55ab1e8 100644
--- a/shared/styles/globals.js
+++ b/shared/styles/globals.js
@@ -38,7 +38,7 @@ export default createGlobalStyle`
}
a {
- color: ${props => props.theme.primary};
+ color: ${props => props.theme.link};
text-decoration: none;
cursor: pointer;
}
diff --git a/shared/styles/theme.js b/shared/styles/theme.js
index a253e080..7b300cea 100644
--- a/shared/styles/theme.js
+++ b/shared/styles/theme.js
@@ -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,
diff --git a/yarn.lock b/yarn.lock
index 4323beb4..db01682a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"