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 {
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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,56 @@
// @flow
import * as React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import styled from "styled-components";
import invariant from "invariant";
import { Link } from "react-router-dom";
import Input from "components/Input";
import Button from "components/Button";
import Flex from "components/Flex";
import Switch from "components/Switch";
import CopyToClipboard from "components/CopyToClipboard";
import HelpText from "components/HelpText";
import Document from "models/Document";
import SharesStore from "stores/SharesStore";
import UiStore from "stores/UiStore";
import PoliciesStore from "stores/PoliciesStore";
type Props = {
document: Document,
shares: SharesStore,
ui: UiStore,
policies: PoliciesStore,
onSubmit: () => void,
};
@observer
class DocumentShare extends React.Component<Props> {
@observable isCopied: boolean;
@observable isSaving: boolean = false;
timeout: TimeoutID;
componentWillUnmount() {
clearTimeout(this.timeout);
}
handlePublishedChange = async event => {
const { document, shares } = this.props;
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
this.isSaving = true;
try {
await share.save({ published: event.target.checked });
} catch (err) {
this.props.ui.showToast(err.message);
} finally {
this.isSaving = false;
}
};
handleCopied = () => {
this.isCopied = true;
@ -33,35 +61,72 @@ class DocumentShare extends React.Component<Props> {
};
render() {
const { document, onSubmit } = this.props;
const { document, policies, shares, onSubmit } = this.props;
const share = shares.getByDocumentId(document.id);
const can = policies.abilities(share ? share.id : "");
return (
<div>
<HelpText>
The link below allows anyone in the world to access a read-only
version of the document <strong>{document.title}</strong>. You can
revoke this link in settings at any time.{" "}
The link below provides a read-only version of the document{" "}
<strong>{document.title}</strong>.{" "}
{can.update &&
"You can optionally make it accessible to anyone with the link."}{" "}
<Link to="/settings/shares" onClick={onSubmit}>
Manage share links
Manage all share links
</Link>.
</HelpText>
{can.update && (
<React.Fragment>
<Switch
id="published"
label="Publish to internet"
onChange={this.handlePublishedChange}
checked={share ? share.published : false}
disabled={!share || this.isSaving}
/>
<Privacy>
{share.published ? <GlobeIcon /> : <PadlockIcon />}
<PrivacyText>
{share.published
? "Anyone with the link can view this document"
: "Only team members with access can view this document"}
</PrivacyText>
</Privacy>
</React.Fragment>
)}
<br />
<Input
type="text"
label="Share link"
value={document.shareUrl || "Loading…"}
label="Get link"
value={share ? share.url : "Loading…"}
labelHidden
readOnly
/>
<CopyToClipboard
text={document.shareUrl || ""}
text={share ? share.url : ""}
onCopy={this.handleCopied}
>
<Button type="submit" disabled={this.isCopied} primary>
<Button type="submit" disabled={this.isCopied || !share} primary>
{this.isCopied ? "Copied!" : "Copy Link"}
</Button>
</CopyToClipboard>
</CopyToClipboard>&nbsp;&nbsp;&nbsp;<a href={share.url} target="_blank">
Preview
</a>
</div>
);
}
}
export default DocumentShare;
const Privacy = styled(Flex)`
flex-align: center;
margin-left: -4px;
`;
const PrivacyText = styled(HelpText)`
margin: 0;
margin-left: 2px;
font-size: 15px;
`;
export default inject("shares", "ui", "policies")(DocumentShare);

View File

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

View File

@ -33,11 +33,27 @@ const description = event => {
return (
<React.Fragment>
{capitalize(event.verbPastTense)} a{" "}
<Link to={`/share/${event.modelId || ""}`}>public link</Link> to the{" "}
<Link to={`/share/${event.modelId || ""}`}>share link</Link> to the{" "}
<Link to={`/doc/${event.documentId}`}>{event.data.name}</Link>{" "}
document
</React.Fragment>
);
case "shares.update":
return (
<React.Fragment>
{event.data.published ? (
<React.Fragment>
Published a document{" "}
<Link to={`/share/${event.modelId || ""}`}>share link</Link>
</React.Fragment>
) : (
<React.Fragment>
Unpublished a document{" "}
<Link to={`/share/${event.modelId || ""}`}>share link</Link>
</React.Fragment>
)}
</React.Fragment>
);
case "users.create":
return (
<React.Fragment>{event.data.name} created an account</React.Fragment>

View File

@ -1,5 +1,6 @@
// @flow
import { sortBy } from "lodash";
import invariant from "invariant";
import { sortBy, filter, find } from "lodash";
import { action, computed } from "mobx";
import { client } from "utils/ApiClient";
import BaseStore from "./BaseStore";
@ -7,7 +8,7 @@ import RootStore from "./RootStore";
import Share from "models/Share";
export default class SharesStore extends BaseStore<Share> {
actions = ["list", "create"];
actions = ["info", "list", "create", "update"];
constructor(rootStore: RootStore) {
super(rootStore, Share);
@ -18,9 +19,44 @@ export default class SharesStore extends BaseStore<Share> {
return sortBy(Array.from(this.data.values()), "createdAt").reverse();
}
@computed
get published(): Share[] {
return filter(this.orderedData, share => share.published);
}
@action
revoke = async (share: Share) => {
await client.post("/shares.revoke", { id: share.id });
this.remove(share.id);
};
@action
async create(params: Object) {
let item = this.getByDocumentId(params.documentId);
if (item) return item;
return super.create(params);
}
@action
async fetch(documentId: string, options?: Object = {}): Promise<*> {
let item = this.getByDocumentId(documentId);
if (item && !options.force) return item;
this.isFetching = true;
try {
const res = await client.post(`/${this.modelName}s.info`, { documentId });
invariant(res && res.data, "Data should be available");
this.addPolicies(res.policies);
return this.add(res.data);
} finally {
this.isFetching = false;
}
}
getByDocumentId = (documentId): ?Share => {
return find(this.orderedData, share => share.documentId === documentId);
};
}

View File

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

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

View File

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

View File

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

View File

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

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.delete",
"shares.create",
"shares.update",
"shares.revoke",
"groups.create",
"groups.update",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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