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 ( ); } @@ -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 && ( - + + )} {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"} + + + + )} +
- - +     + 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"