From dded458582ef502aafa759a115a9f1bb9be6b02d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 12 May 2018 21:01:17 -0700 Subject: [PATCH 01/23] Setup for unauthenticated doc viewing --- app/components/Auth.js | 5 --- .../DocumentPreview/DocumentPreview.js | 9 +++++- .../Sidebar/components/DocumentLink.js | 5 ++- .../Sidebar/components/SidebarLink.js | 5 ++- app/index.js | 5 +++ app/scenes/Document/Document.js | 17 ++++------ app/stores/DocumentsStore.js | 32 ++----------------- app/stores/index.js | 9 ++++-- 8 files changed, 33 insertions(+), 54 deletions(-) diff --git a/app/components/Auth.js b/app/components/Auth.js index b3cd00296..6a9d3f867 100644 --- a/app/components/Auth.js +++ b/app/components/Auth.js @@ -4,7 +4,6 @@ import { Provider } from 'mobx-react'; import stores from 'stores'; import ApiKeysStore from 'stores/ApiKeysStore'; import UsersStore from 'stores/UsersStore'; -import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; import IntegrationsStore from 'stores/IntegrationsStore'; import CacheStore from 'stores/CacheStore'; @@ -27,10 +26,6 @@ const Auth = ({ children }: Props) => { integrations: new IntegrationsStore(), apiKeys: new ApiKeysStore(), users: new UsersStore(), - documents: new DocumentsStore({ - ui: stores.ui, - cache, - }), collections: new CollectionsStore({ ui: stores.ui, teamId: team.id, diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index d6026c353..e5c303e7d 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -112,7 +112,14 @@ class DocumentPreview extends React.Component { } = this.props; return ( - + {document.publishedAt && ( diff --git a/app/components/Sidebar/components/DocumentLink.js b/app/components/Sidebar/components/DocumentLink.js index dd47ae7e7..43bd9eb24 100644 --- a/app/components/Sidebar/components/DocumentLink.js +++ b/app/components/Sidebar/components/DocumentLink.js @@ -60,7 +60,10 @@ class DocumentLink extends React.Component { activeClassName="activeDropZone" > ) => *, children?: React.Node, icon?: React.Node, @@ -59,7 +59,6 @@ type Props = { active?: boolean, }; -@withRouter @observer class SidebarLink extends React.Component { @observable expanded: boolean = false; @@ -158,4 +157,4 @@ const Content = styled.div` width: 100%; `; -export default SidebarLink; +export default withRouter(SidebarLink); diff --git a/app/index.js b/app/index.js index bb84a01c9..d64a6db5b 100644 --- a/app/index.js +++ b/app/index.js @@ -68,6 +68,11 @@ if (element) { /> + diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index 3d952d64a..f0fb6fccb 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -1,6 +1,5 @@ // @flow import * as React from 'react'; -import get from 'lodash/get'; import debounce from 'lodash/debounce'; import styled from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; @@ -26,7 +25,6 @@ import Actions from './components/Actions'; import DocumentMove from './components/DocumentMove'; import UiStore from 'stores/UiStore'; import DocumentsStore from 'stores/DocumentsStore'; -import CollectionsStore from 'stores/CollectionsStore'; import LoadingPlaceholder from 'components/LoadingPlaceholder'; import LoadingIndicator from 'components/LoadingIndicator'; import CenteredContent from 'components/CenteredContent'; @@ -44,7 +42,6 @@ type Props = { history: Object, location: Location, documents: DocumentsStore, - collections: CollectionsStore, newDocument?: boolean, ui: UiStore, }; @@ -237,19 +234,19 @@ class DocumentScene extends React.Component { }; render() { + const { location, match } = this.props; const Editor = this.editorComponent; - const isMoving = this.props.match.path === matchDocumentMove; + const isMoving = match.path === matchDocumentMove; const document = this.document; - const titleText = - get(document, 'title', '') || - this.props.collections.titleForDocument(this.props.location.pathname); + const titleFromState = location.state ? location.state.title : ''; + const titleText = document ? document.title : titleFromState; if (this.notFound) { return ; } return ( - + {isMoving && document && } {titleText && } {(this.isLoading || this.isSaving) && } @@ -322,6 +319,4 @@ const LoadingState = styled(LoadingPlaceholder)` margin: 90px 0; `; -export default withRouter( - inject('ui', 'user', 'documents', 'collections')(DocumentScene) -); +export default withRouter(inject('ui', 'user', 'documents')(DocumentScene)); diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index d161deaff..8ffba9c54 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -1,29 +1,18 @@ // @flow -import { - observable, - action, - computed, - ObservableMap, - runInAction, - autorunAsync, -} from 'mobx'; +import { observable, action, computed, ObservableMap, runInAction } from 'mobx'; import { client } from 'utils/ApiClient'; import _ from 'lodash'; import invariant from 'invariant'; import BaseStore from 'stores/BaseStore'; -import stores from 'stores'; import Document from 'models/Document'; import ErrorsStore from 'stores/ErrorsStore'; -import CacheStore from 'stores/CacheStore'; import UiStore from 'stores/UiStore'; import type { PaginationParams } from 'types'; -const DOCUMENTS_CACHE_KEY = 'DOCUMENTS_CACHE_KEY'; export const DEFAULT_PAGINATION_LIMIT = 25; type Options = { - cache: CacheStore, ui: UiStore, }; @@ -35,7 +24,6 @@ class DocumentsStore extends BaseStore { @observable isFetching: boolean = false; errors: ErrorsStore; - cache: CacheStore; ui: UiStore; /* Computed */ @@ -228,16 +216,9 @@ class DocumentsStore extends BaseStore { constructor(options: Options) { super(); - this.errors = stores.errors; - this.cache = options.cache; + this.errors = options.errors; this.ui = options.ui; - this.cache.getItem(DOCUMENTS_CACHE_KEY).then(data => { - if (data) { - data.forEach(document => this.add(new Document(document))); - } - }); - this.on('documents.delete', (data: { id: string }) => { this.remove(data.id); }); @@ -254,15 +235,6 @@ class DocumentsStore extends BaseStore { this.fetchRecentlyModified(); this.fetchRecentlyViewed(); }); - - autorunAsync('DocumentsStore.persists', () => { - if (this.data.size) { - this.cache.setItem( - DOCUMENTS_CACHE_KEY, - Array.from(this.data.values()).map(collection => collection.data) - ); - } - }); } } diff --git a/app/stores/index.js b/app/stores/index.js index ccfaa9c8c..f175941d5 100644 --- a/app/stores/index.js +++ b/app/stores/index.js @@ -2,13 +2,16 @@ import AuthStore from './AuthStore'; import UiStore from './UiStore'; import ErrorsStore from './ErrorsStore'; +import DocumentsStore from './DocumentsStore'; +const ui = new UiStore(); +const errors = new ErrorsStore(); const stores = { user: null, // Including for Layout auth: new AuthStore(), - ui: new UiStore(), - errors: new ErrorsStore(), + ui, + errors, + documents: new DocumentsStore({ ui, errors }), }; -window.stores = stores; export default stores; From 9000aa3aac074b0638237b4c47d94f1141705e20 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 12 May 2018 23:14:06 -0700 Subject: [PATCH 02/23] First pass at API --- server/api/__snapshots__/shares.test.js.snap | 19 ++++++ server/api/index.js | 2 + server/api/shares.js | 64 +++++++++++++++++++ server/api/shares.test.js | 61 ++++++++++++++++++ .../20180513041057-add-share-links.js | 44 +++++++++++++ server/models/Share.js | 27 ++++++++ server/models/index.js | 39 +++++------ server/policies/document.js | 2 +- server/presenters/index.js | 2 + server/presenters/share.js | 15 +++++ 10 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 server/api/__snapshots__/shares.test.js.snap create mode 100644 server/api/shares.js create mode 100644 server/api/shares.test.js create mode 100644 server/migrations/20180513041057-add-share-links.js create mode 100644 server/models/Share.js create mode 100644 server/presenters/share.js diff --git a/server/api/__snapshots__/shares.test.js.snap b/server/api/__snapshots__/shares.test.js.snap new file mode 100644 index 000000000..77cc5d4df --- /dev/null +++ b/server/api/__snapshots__/shares.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#shares.create 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", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/api/index.js b/server/api/index.js index 45ae7c19a..26e9e1dc3 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -12,6 +12,7 @@ import documents from './documents'; import views from './views'; import hooks from './hooks'; import apiKeys from './apiKeys'; +import shares from './shares'; import team from './team'; import integrations from './integrations'; @@ -74,6 +75,7 @@ router.use('/', documents.routes()); router.use('/', views.routes()); router.use('/', hooks.routes()); router.use('/', apiKeys.routes()); +router.use('/', shares.routes()); router.use('/', team.routes()); router.use('/', integrations.routes()); diff --git a/server/api/shares.js b/server/api/shares.js new file mode 100644 index 000000000..ee4a46dbd --- /dev/null +++ b/server/api/shares.js @@ -0,0 +1,64 @@ +// @flow +import Router from 'koa-router'; +import auth from './middlewares/authentication'; +import pagination from './middlewares/pagination'; +import { presentShare } from '../presenters'; +import { Document, User, Share } from '../models'; +import policy from '../policies'; + +const { authorize } = policy; +const router = new Router(); + +router.post('shares.list', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const shares = await Share.findAll({ + where: { teamId: user.teamId }, + order: [[sort, direction]], + include: [ + { + model: Document, + required: true, + as: 'document', + }, + { + model: User, + required: true, + as: 'user', + }, + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const data = await Promise.all(shares.map(share => presentShare(ctx, share))); + + ctx.body = { + data, + }; +}); + +router.post('shares.create', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const document = await Document.findById(id); + authorize(user, 'share', document); + + const share = await Share.create({ + documentId: document.id, + userId: user.id, + teamId: user.teamId, + }); + share.user = user; + share.document = document; + + ctx.body = { + data: presentShare(ctx, share), + }; +}); + +export default router; diff --git a/server/api/shares.test.js b/server/api/shares.test.js new file mode 100644 index 000000000..02e437f6a --- /dev/null +++ b/server/api/shares.test.js @@ -0,0 +1,61 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from 'fetch-test-server'; +import app from '..'; +import { flushdb, seed } from '../test/support'; +import { buildUser } from '../test/factories'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(server.close); + +describe('#shares.list', async () => { + it('should return a list of shares', async () => { + const { user } = await seed(); + const res = await server.post('/api/shares.list', { + body: { token: user.getJwtToken() }, + }); + expect(res.status).toEqual(200); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/shares.list'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#shares.create', async () => { + it('should allow creating a share record for document', async () => { + const { user, document } = await seed(); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), id: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.documentTitle).toBe(document.title); + }); + + it('should require authentication', async () => { + const { document } = await seed(); + const res = await server.post('/api/shares.create', { + body: { id: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const { document } = await seed(); + const user = await buildUser(); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), id: document.id }, + }); + expect(res.status).toEqual(403); + }); +}); diff --git a/server/migrations/20180513041057-add-share-links.js b/server/migrations/20180513041057-add-share-links.js new file mode 100644 index 000000000..5d67446f9 --- /dev/null +++ b/server/migrations/20180513041057-add-share-links.js @@ -0,0 +1,44 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('shares', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + }, + }, + teamId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'teams', + }, + }, + documentId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'documents', + }, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('shares'); + }, +}; diff --git a/server/models/Share.js b/server/models/Share.js new file mode 100644 index 000000000..3965b8642 --- /dev/null +++ b/server/models/Share.js @@ -0,0 +1,27 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const Share = sequelize.define('share', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, +}); + +Share.associate = models => { + Share.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + }); + Share.belongsTo(models.Team, { + as: 'team', + foreignKey: 'teamId', + }); + Share.belongsTo(models.Document, { + as: 'document', + foreignKey: 'documentId', + }); +}; + +export default Share; diff --git a/server/models/index.js b/server/models/index.js index 8c50e5951..16d1606a5 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,28 +1,30 @@ // @flow +import ApiKey from './ApiKey'; import Authentication from './Authentication'; -import Integration from './Integration'; -import Event from './Event'; -import User from './User'; -import Team from './Team'; import Collection from './Collection'; import Document from './Document'; +import Event from './Event'; +import Integration from './Integration'; import Revision from './Revision'; -import ApiKey from './ApiKey'; -import View from './View'; +import Share from './Share'; import Star from './Star'; +import Team from './Team'; +import User from './User'; +import View from './View'; const models = { + ApiKey, Authentication, - Integration, - Event, - User, - Team, Collection, Document, + Event, + Integration, Revision, - ApiKey, - View, + Share, Star, + Team, + User, + View, }; // based on https://github.com/sequelize/express-example/blob/master/models/index.js @@ -33,15 +35,16 @@ Object.keys(models).forEach(modelName => { }); export { + ApiKey, Authentication, - Integration, - Event, - User, - Team, Collection, Document, + Event, + Integration, Revision, - ApiKey, - View, + Share, Star, + Team, + User, + View, }; diff --git a/server/policies/document.js b/server/policies/document.js index 60f6afbbf..cbe3be79a 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -8,7 +8,7 @@ allow(User, 'create', Document); allow( User, - ['read', 'update', 'delete'], + ['read', 'update', 'delete', 'share'], Document, (user, document) => user.teamId === document.teamId ); diff --git a/server/presenters/index.js b/server/presenters/index.js index 3ec608df6..3796c23fa 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -5,6 +5,7 @@ import presentDocument from './document'; import presentRevision from './revision'; import presentCollection from './collection'; import presentApiKey from './apiKey'; +import presentShare from './share'; import presentTeam from './team'; import presentIntegration from './integration'; import presentSlackAttachment from './slackAttachment'; @@ -16,6 +17,7 @@ export { presentRevision, presentCollection, presentApiKey, + presentShare, presentTeam, presentIntegration, presentSlackAttachment, diff --git a/server/presenters/share.js b/server/presenters/share.js new file mode 100644 index 000000000..c6d87d935 --- /dev/null +++ b/server/presenters/share.js @@ -0,0 +1,15 @@ +// @flow +import { Share } from '../models'; +import { presentUser } from '.'; + +function present(ctx: Object, share: Share) { + return { + id: share.id, + user: presentUser(ctx, share.user), + documentTitle: share.document.title, + createdAt: share.createdAt, + updatedAt: share.updatedAt, + }; +} + +export default present; From 22bc5a737350f8a5f60fa9d65c0b402107606862 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 13 May 2018 00:28:31 -0700 Subject: [PATCH 03/23] Added delete endpoint --- Makefile | 5 ++++- package.json | 1 + server/api/shares.js | 14 ++++++++++++++ server/api/shares.test.js | 13 +++++++++++-- server/policies/index.js | 1 + server/policies/share.js | 15 +++++++++++++++ server/test/factories.js | 15 ++++++++++++++- 7 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 server/policies/share.js diff --git a/Makefile b/Makefile index 5080bbd8b..6b4bb0bab 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,11 @@ build: test: docker-compose run --rm outline yarn test +watch: + docker-compose run --rm outline yarn test:watch + destroy: docker-compose stop docker-compose rm -f -.PHONY: up build destroy # let's go to reserve rules names +.PHONY: up build destroy test watch # let's go to reserve rules names diff --git a/package.json b/package.json index ee57b100c..e48d715fe 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test": "npm run test:app && npm run test:server", "test:app": "jest", "test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit", + "test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll", "precommit": "lint-staged" }, "lint-staged": { diff --git a/server/api/shares.js b/server/api/shares.js index ee4a46dbd..16cfd7edc 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -61,4 +61,18 @@ router.post('shares.create', auth(), async ctx => { }; }); +router.post('shares.delete', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const share = await Share.findById(id); + authorize(ctx.state.user, 'delete', share); + + await share.destroy(); + + ctx.body = { + success: true, + }; +}); + export default router; diff --git a/server/api/shares.test.js b/server/api/shares.test.js index 02e437f6a..c9d78f851 100644 --- a/server/api/shares.test.js +++ b/server/api/shares.test.js @@ -2,7 +2,7 @@ import TestServer from 'fetch-test-server'; import app from '..'; import { flushdb, seed } from '../test/support'; -import { buildUser } from '../test/factories'; +import { buildUser, buildShare } from '../test/factories'; const server = new TestServer(app.callback()); @@ -11,11 +11,20 @@ afterAll(server.close); describe('#shares.list', async () => { it('should return a list of shares', async () => { - const { user } = await seed(); + const { user, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + }); 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(1); + expect(body.data[0].id).toEqual(share.id); + expect(body.data[0].documentTitle).toBe(document.title); }); it('should require authentication', async () => { diff --git a/server/policies/index.js b/server/policies/index.js index b9ac4f5a7..2e1492403 100644 --- a/server/policies/index.js +++ b/server/policies/index.js @@ -4,6 +4,7 @@ import './apiKey'; import './collection'; import './document'; import './integration'; +import './share'; import './user'; export default policy; diff --git a/server/policies/share.js b/server/policies/share.js new file mode 100644 index 000000000..9872103d8 --- /dev/null +++ b/server/policies/share.js @@ -0,0 +1,15 @@ +// @flow +import policy from './policy'; +import { Share, User } from '../models'; +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, ['delete'], Share, (user, share) => { + if (!share || user.teamId !== share.teamId) return false; + if (user.id === share.userId) return false; + if (user.isAdmin) return true; + throw new AdminRequiredError(); +}); diff --git a/server/test/factories.js b/server/test/factories.js index 92e6fb5b1..b76209087 100644 --- a/server/test/factories.js +++ b/server/test/factories.js @@ -1,9 +1,22 @@ // @flow -import { Team, User } from '../models'; +import { Share, Team, User } from '../models'; import uuid from 'uuid'; let count = 0; +export async function buildShare(overrides: Object = {}) { + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + if (!overrides.userId) { + const user = await buildUser({ teamId: overrides.teamId }); + overrides.userId = user.id; + } + + return Share.create(overrides); +} + export function buildTeam(overrides: Object = {}) { count++; From 500d0398566d4f0595604dc77ca2c4dd81adb7eb Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 13 May 2018 13:26:06 -0700 Subject: [PATCH 04/23] Retrieve documents using shareId --- app/stores/DocumentsStore.js | 21 ++++-- server/api/documents.js | 26 +++++-- server/api/documents.test.js | 18 ++++- server/api/middlewares/authentication.js | 91 ++++++++++++------------ 4 files changed, 100 insertions(+), 56 deletions(-) diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 8ffba9c54..7aafcc14d 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -14,6 +14,12 @@ export const DEFAULT_PAGINATION_LIMIT = 25; type Options = { ui: UiStore, + errors: ErrorsStore, +}; + +type FetchOptions = { + prefetch?: boolean, + shareId?: string, }; class DocumentsStore extends BaseStore { @@ -166,15 +172,20 @@ class DocumentsStore extends BaseStore { @action prefetchDocument = async (id: string) => { - if (!this.getById(id)) this.fetch(id, true); + if (!this.getById(id)) { + this.fetch(id, { prefetch: true }); + } }; @action - fetch = async (id: string, prefetch?: boolean): Promise<*> => { - if (!prefetch) this.isFetching = true; + fetch = async (id: string, options?: FetchOptions = {}): Promise<*> => { + if (!options.prefetch) this.isFetching = true; try { - const res = await client.post('/documents.info', { id }); + const res = await client.post('/documents.info', { + id, + shareId: options.shareId, + }); invariant(res && res.data, 'Document not available'); const { data } = res; const document = new Document(data); @@ -186,7 +197,7 @@ class DocumentsStore extends BaseStore { return document; } catch (e) { - this.errors.add('Failed to load documents'); + this.errors.add('Failed to load document'); } finally { this.isFetching = false; } diff --git a/server/api/documents.js b/server/api/documents.js index f17e7bbb9..25e1c8769 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -4,7 +4,7 @@ import Sequelize from 'sequelize'; import auth from './middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentDocument, presentRevision } from '../presenters'; -import { Document, Collection, Star, View, Revision } from '../models'; +import { Document, Collection, Share, Star, View, Revision } from '../models'; import { InvalidRequestError } from '../errors'; import events from '../events'; import policy from '../policies'; @@ -157,12 +157,26 @@ router.post('documents.drafts', auth(), pagination(), async ctx => { }; }); -router.post('documents.info', auth(), async ctx => { - const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); - const document = await Document.findById(id); +router.post('documents.info', auth({ required: false }), async ctx => { + const { id, shareId } = ctx.body; + ctx.assertPresent(id || shareId, 'id or shareId is required'); - authorize(ctx.state.user, 'read', document); + let document; + if (shareId) { + const share = await Share.findById(shareId, { + include: [ + { + model: Document, + required: true, + as: 'document', + }, + ], + }); + document = share.document; + } else { + document = await Document.findById(id); + authorize(ctx.state.user, 'read', document); + } ctx.body = { data: await presentDocument(ctx, document), diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 20280ffbe..ccc4f81ad 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -3,7 +3,7 @@ import TestServer from 'fetch-test-server'; import app from '..'; import { Document, View, Star, Revision } from '../models'; import { flushdb, seed } from '../test/support'; -import { buildUser } from '../test/factories'; +import { buildShare, buildUser } from '../test/factories'; const server = new TestServer(app.callback()); @@ -35,6 +35,22 @@ describe('#documents.info', async () => { expect(res.status).toEqual(200); expect(body.data.id).toEqual(document.id); }); + + it('should return documents from shareId', async () => { + const { user, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: document.teamId, + }); + + const res = await server.post('/api/documents.info', { + body: { token: user.getJwtToken(), shareId: share.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(document.id); + }); }); describe('#documents.list', async () => { diff --git a/server/api/middlewares/authentication.js b/server/api/middlewares/authentication.js index 26e06a281..3cd33ab67 100644 --- a/server/api/middlewares/authentication.js +++ b/server/api/middlewares/authentication.js @@ -4,7 +4,7 @@ import { type Context } from 'koa'; import { User, ApiKey } from '../../models'; import { AuthenticationError, UserSuspendedError } from '../../errors'; -export default function auth() { +export default function auth(options?: { required?: boolean } = {}) { return async function authMiddleware( ctx: Context, next: () => Promise @@ -33,58 +33,61 @@ export default function auth() { token = ctx.request.query.token; } - if (!token) throw new AuthenticationError('Authentication required'); + if (!token && options.required !== false) { + throw new AuthenticationError('Authentication required'); + } let user; + if (token) { + if (String(token).match(/^[\w]{38}$/)) { + // API key + let apiKey; + try { + apiKey = await ApiKey.findOne({ + where: { + secret: token, + }, + }); + } catch (e) { + throw new AuthenticationError('Invalid API key'); + } - if (String(token).match(/^[\w]{38}$/)) { - // API key - let apiKey; - try { - apiKey = await ApiKey.findOne({ - where: { - secret: token, - }, - }); - } catch (e) { - throw new AuthenticationError('Invalid API key'); + if (!apiKey) throw new AuthenticationError('Invalid API key'); + + user = await User.findById(apiKey.userId); + if (!user) throw new AuthenticationError('Invalid API key'); + } else { + // JWT + // Get user without verifying payload signature + let payload; + try { + payload = JWT.decode(token); + } catch (e) { + throw new AuthenticationError('Unable to decode JWT token'); + } + + if (!payload) throw new AuthenticationError('Invalid token'); + + user = await User.findById(payload.id); + + try { + JWT.verify(token, user.jwtSecret); + } catch (e) { + throw new AuthenticationError('Invalid token'); + } } - if (!apiKey) throw new AuthenticationError('Invalid API key'); - - user = await User.findById(apiKey.userId); - if (!user) throw new AuthenticationError('Invalid API key'); - } else { - // JWT - // Get user without verifying payload signature - let payload; - try { - payload = JWT.decode(token); - } catch (e) { - throw new AuthenticationError('Unable to decode JWT token'); + if (user.isSuspended) { + const suspendingAdmin = await User.findById(user.suspendedById); + throw new UserSuspendedError({ adminEmail: suspendingAdmin.email }); } - if (!payload) throw new AuthenticationError('Invalid token'); - - user = await User.findById(payload.id); - - try { - JWT.verify(token, user.jwtSecret); - } catch (e) { - throw new AuthenticationError('Invalid token'); - } + ctx.state.token = token; + ctx.state.user = user; + // $FlowFixMe + ctx.cache[user.id] = user; } - if (user.isSuspended) { - const suspendingAdmin = await User.findById(user.suspendedById); - throw new UserSuspendedError({ adminEmail: suspendingAdmin.email }); - } - - ctx.state.token = token; - ctx.state.user = user; - // $FlowFixMe - ctx.cache[user.id] = user; - return next(); }; } From 4266020315959ddba317f069e1a6f6beb936065a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 16 May 2018 23:07:33 -0700 Subject: [PATCH 05/23] Gen and copy share link from frontend --- .../CopyToClipboard/CopyToClipboard.js | 4 +- app/components/Modals/Modals.js | 4 ++ app/components/Sidebar/Main.js | 4 -- app/menus/DocumentMenu.js | 13 +++++ app/models/Document.js | 13 +++++ app/scenes/DocumentShare/DocumentShare.js | 58 +++++++++++++++++++ app/scenes/DocumentShare/index.js | 3 + server/presenters/share.js | 1 + 8 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 app/scenes/DocumentShare/DocumentShare.js create mode 100644 app/scenes/DocumentShare/index.js diff --git a/app/components/CopyToClipboard/CopyToClipboard.js b/app/components/CopyToClipboard/CopyToClipboard.js index a1deca607..cf762a048 100644 --- a/app/components/CopyToClipboard/CopyToClipboard.js +++ b/app/components/CopyToClipboard/CopyToClipboard.js @@ -5,8 +5,8 @@ import copy from 'copy-to-clipboard'; type Props = { text: string, children?: React.Node, - onClick?: () => void, - onCopy: () => void, + onClick?: () => *, + onCopy: () => *, }; class CopyToClipboard extends React.PureComponent { diff --git a/app/components/Modals/Modals.js b/app/components/Modals/Modals.js index 81506c74d..d9a2c23c2 100644 --- a/app/components/Modals/Modals.js +++ b/app/components/Modals/Modals.js @@ -7,6 +7,7 @@ import CollectionNew from 'scenes/CollectionNew'; import CollectionEdit from 'scenes/CollectionEdit'; import CollectionDelete from 'scenes/CollectionDelete'; import DocumentDelete from 'scenes/DocumentDelete'; +import DocumentShare from 'scenes/DocumentShare'; import KeyboardShortcuts from 'scenes/KeyboardShortcuts'; type Props = { @@ -44,6 +45,9 @@ class Modals extends React.Component { + + + diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 8c6db43da..16fe19602 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -31,10 +31,6 @@ class MainSidebar extends React.Component { this.props.ui.setActiveModal('collection-new'); }; - handleEditCollection = () => { - this.props.ui.setActiveModal('collection-edit'); - }; - render() { const { auth, documents } = this.props; const { user, team } = auth; diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 6e06365d1..88c13fcbb 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -56,6 +56,13 @@ class DocumentMenu extends React.Component { this.props.document.download(); }; + handleShareLink = async (ev: SyntheticEvent<*>) => { + const { document } = this.props; + if (!document.shareUrl) await document.share(); + + this.props.ui.setActiveModal('document-share', { document }); + }; + render() { const { document, label, className, showPrint } = this.props; const isDraft = !document.publishedAt; @@ -80,6 +87,12 @@ class DocumentMenu extends React.Component { Star )} + + Share link +
{ + try { + const res = await client.post('/shares.create', { id: this.id }); + invariant(res && res.data, 'Document API response should be available'); + + this.shareUrl = res.data.url; + } catch (e) { + this.errors.add('Document failed to share'); + } + }; + @action pin = async () => { this.pinned = true; diff --git a/app/scenes/DocumentShare/DocumentShare.js b/app/scenes/DocumentShare/DocumentShare.js new file mode 100644 index 000000000..a0ecaf3fb --- /dev/null +++ b/app/scenes/DocumentShare/DocumentShare.js @@ -0,0 +1,58 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import Input from 'components/Input'; +import Button from 'components/Button'; +import CopyToClipboard from 'components/CopyToClipboard'; +import HelpText from 'components/HelpText'; +import Document from 'models/Document'; + +type Props = { + document: Document, +}; + +@observer +class DocumentShare extends React.Component { + @observable isCopied: boolean; + timeout: TimeoutID; + + componentWillUnmount() { + clearTimeout(this.timeout); + } + + handleCopied = () => { + this.isCopied = true; + this.timeout = setTimeout(() => (this.isCopied = false), 3000); + }; + + render() { + const { document } = this.props; + + return ( +
+ + The link below allows anyone to access a read-only version of the + document {document.title}. You can revoke this link + at any point in the future. + + + + + +
+ ); + } +} + +export default DocumentShare; diff --git a/app/scenes/DocumentShare/index.js b/app/scenes/DocumentShare/index.js new file mode 100644 index 000000000..c480add32 --- /dev/null +++ b/app/scenes/DocumentShare/index.js @@ -0,0 +1,3 @@ +// @flow +import DocumentShare from './DocumentShare'; +export default DocumentShare; diff --git a/server/presenters/share.js b/server/presenters/share.js index c6d87d935..30977cde1 100644 --- a/server/presenters/share.js +++ b/server/presenters/share.js @@ -7,6 +7,7 @@ function present(ctx: Object, share: Share) { id: share.id, user: presentUser(ctx, share.user), documentTitle: share.document.title, + url: `${process.env.URL}/share/${share.id}`, createdAt: share.createdAt, updatedAt: share.updatedAt, }; From 187c2dcb27707c39b9469855bcd9268d25f7d5b4 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 16 May 2018 23:52:26 -0700 Subject: [PATCH 06/23] Working share link loading (plenty of restrictions still to put in place) --- app/index.js | 6 +--- app/scenes/Document/Document.js | 60 +++++++++++++++------------------ app/stores/DocumentsStore.js | 3 ++ server/api/documents.js | 2 ++ 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/app/index.js b/app/index.js index d64a6db5b..394ee5be4 100644 --- a/app/index.js +++ b/app/index.js @@ -68,11 +68,7 @@ if (element) { /> - + diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index f0fb6fccb..658585461 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -24,6 +24,7 @@ import Document from 'models/Document'; import Actions from './components/Actions'; import DocumentMove from './components/DocumentMove'; import UiStore from 'stores/UiStore'; +import AuthStore from 'stores/AuthStore'; import DocumentsStore from 'stores/DocumentsStore'; import LoadingPlaceholder from 'components/LoadingPlaceholder'; import LoadingIndicator from 'components/LoadingIndicator'; @@ -43,6 +44,7 @@ type Props = { location: Location, documents: DocumentsStore, newDocument?: boolean, + auth: AuthStore, ui: UiStore, }; @@ -52,6 +54,7 @@ class DocumentScene extends React.Component { @observable editorComponent; @observable editCache: ?string; + @observable document: ?Document; @observable newDocument: ?Document; @observable isLoading = false; @observable isSaving = false; @@ -87,7 +90,7 @@ class DocumentScene extends React.Component { loadDocument = async props => { if (props.newDocument) { - const newDocument = new Document({ + this.document = new Document({ collection: { id: props.match.params.id }, parentDocument: new URLSearchParams(props.location.search).get( 'parentDocument' @@ -95,32 +98,30 @@ class DocumentScene extends React.Component { title: '', text: '', }); - this.newDocument = newDocument; } else { - let document = this.getDocument(props.match.params.documentSlug); + this.document = await this.props.documents.fetch( + props.match.params.documentSlug, + { shareId: props.match.params.shareId } + ); - if (document) { - this.props.documents.fetch(props.match.params.documentSlug); - this.props.ui.setActiveDocument(document); - } else { - document = await this.props.documents.fetch( - props.match.params.documentSlug - ); - } + const document = this.document; if (document) { this.props.ui.setActiveDocument(document); // Cache data if user enters edit mode and cancels this.editCache = document.text; - if (!this.isEditing && document.publishedAt) { - document.view(); - } - // Update url to match the current one - this.props.history.replace( - updateDocumentUrl(props.match.url, document.url) - ); + if (this.props.auth.user) { + if (!this.isEditing && document.publishedAt) { + document.view(); + } + + // Update url to match the current one + this.props.history.replace( + updateDocumentUrl(props.match.url, document.url) + ); + } } else { // Render 404 with search this.notFound = true; @@ -134,22 +135,14 @@ class DocumentScene extends React.Component { }; get isEditing() { + const document = this.document; + return !!( - this.props.match.path === matchDocumentEdit || this.props.newDocument + this.props.match.path === matchDocumentEdit || + (document && !document.id) ); } - getDocument(documentSlug: ?string) { - if (this.newDocument) return this.newDocument; - return this.props.documents.getByUrl( - `/doc/${documentSlug || this.props.match.params.documentSlug}` - ); - } - - get document() { - return this.getDocument(); - } - handleCloseMoveModal = () => (this.moveModalOpen = false); handleOpenMoveModal = () => (this.moveModalOpen = true); @@ -159,6 +152,7 @@ class DocumentScene extends React.Component { let document = this.document; if (!document || !document.allowSave) return; + let isNew = !document.id; this.editCache = null; this.isSaving = true; this.isPublishing = !!options.publish; @@ -169,7 +163,7 @@ class DocumentScene extends React.Component { if (options.done) { this.props.history.push(document.url); this.props.ui.setActiveDocument(document); - } else if (this.props.newDocument) { + } else if (isNew) { this.props.history.push(documentEditUrl(document)); this.props.ui.setActiveDocument(document); } @@ -246,7 +240,7 @@ class DocumentScene extends React.Component { } return ( - + {isMoving && document && } {titleText && } {(this.isLoading || this.isSaving) && } @@ -319,4 +313,4 @@ const LoadingState = styled(LoadingPlaceholder)` margin: 90px 0; `; -export default withRouter(inject('ui', 'user', 'documents')(DocumentScene)); +export default withRouter(inject('ui', 'auth', 'documents')(DocumentScene)); diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 7aafcc14d..08ce80044 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -182,6 +182,9 @@ class DocumentsStore extends BaseStore { if (!options.prefetch) this.isFetching = true; try { + const doc = this.getById(id) || this.getByUrl(id); + if (doc) return doc; + const res = await client.post('/documents.info', { id, shareId: options.shareId, diff --git a/server/api/documents.js b/server/api/documents.js index 25e1c8769..6219c36f3 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -172,6 +172,8 @@ router.post('documents.info', auth({ required: false }), async ctx => { }, ], }); + + // TODO: REMOVE COLLECTION AND COLLABORATOR INFO document = share.document; } else { document = await Document.findById(id); From d93815ca0aa60e4a55b192162d7beccce1afe3a4 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 22 May 2018 23:01:49 -0700 Subject: [PATCH 07/23] Share links list WIP --- app/components/Avatar/Avatar.js | 17 ++++++-- app/components/Button/Button.js | 11 ++++-- app/components/List/Item.js | 56 ++++++++++++++++++++++++++ app/components/List/List.js | 10 +++++ app/components/List/index.js | 3 ++ app/components/Sidebar/Settings.js | 11 +++++- app/index.js | 2 + app/scenes/Settings/Shares.js | 63 ++++++++++++++++++++++++++++++ app/stores/SharesStore.js | 43 ++++++++++++++++++++ app/stores/index.js | 2 + app/types/index.js | 9 +++++ server/emails/components/Button.js | 6 ++- server/presenters/share.js | 2 +- 13 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 app/components/List/Item.js create mode 100644 app/components/List/List.js create mode 100644 app/components/List/index.js create mode 100644 app/scenes/Settings/Shares.js create mode 100644 app/stores/SharesStore.js diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js index a71e4bf32..c192e28d1 100644 --- a/app/components/Avatar/Avatar.js +++ b/app/components/Avatar/Avatar.js @@ -6,10 +6,19 @@ import { observer } from 'mobx-react'; import { color } from 'shared/styles/constants'; import placeholder from './placeholder.png'; +type Props = { + src: string, + size: number, +}; + @observer -class Avatar extends React.Component<*> { +class Avatar extends React.Component { @observable error: boolean; + static defaultProps = { + size: 24, + }; + handleError = () => { this.error = true; }; @@ -17,7 +26,7 @@ class Avatar extends React.Component<*> { render() { return ( @@ -26,8 +35,8 @@ class Avatar extends React.Component<*> { } const CircleImg = styled.img` - width: 24px; - height: 24px; + width: ${props => props.size}px; + height: ${props => props.size}px; border-radius: 50%; border: 2px solid ${color.white}; flex-shrink: 0; diff --git a/app/components/Button/Button.js b/app/components/Button/Button.js index 7ddaafd70..5c89e8f34 100644 --- a/app/components/Button/Button.js +++ b/app/components/Button/Button.js @@ -2,7 +2,7 @@ import * as React from 'react'; import styled from 'styled-components'; import { color } from 'shared/styles/constants'; -import { darken, lighten } from 'polished'; +import { darken } from 'polished'; const RealButton = styled.button` display: inline-block; @@ -40,11 +40,14 @@ const RealButton = styled.button` ${props => props.light && ` - color: ${color.text}; - background: ${lighten(0.08, color.slateLight)}; + color: ${color.slate}; + background: transparent; + border: 1px solid ${color.slate}; &:hover { - background: ${color.slateLight}; + background: transparent; + color: ${color.slateDark}; + border: 1px solid ${color.slateDark}; } `} ${props => props.neutral && diff --git a/app/components/List/Item.js b/app/components/List/Item.js new file mode 100644 index 000000000..7fd7bfaad --- /dev/null +++ b/app/components/List/Item.js @@ -0,0 +1,56 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { color, fontSize } from 'shared/styles/constants'; + +type Props = { + image?: React.Node, + title: string, + subtitle: React.Node, + actions?: React.Node, +}; + +const ListItem = ({ image, title, subtitle, actions }: Props) => { + return ( + + {image && {image}} + + {title} + {subtitle} + + {actions && {actions}} + + ); +}; + +const Wrapper = styled.li` + display: flex; + padding: 12px 0; + margin: 0; + border-bottom: 1px solid ${color.smokeDark}; +`; + +const Image = styled.div` + padding: 0 8px 0 0; +`; + +const Heading = styled.h2` + font-size: ${fontSize.medium}; + margin: 0; +`; + +const Content = styled.div` + flex-grow: 1; +`; + +const Subtitle = styled.p` + margin: 0; + font-size: ${fontSize.small}; + color: ${color.slate}; +`; + +const Actions = styled.div` + align-self: flex-end; +`; + +export default ListItem; diff --git a/app/components/List/List.js b/app/components/List/List.js new file mode 100644 index 000000000..892580d6b --- /dev/null +++ b/app/components/List/List.js @@ -0,0 +1,10 @@ +// @flow +import styled from 'styled-components'; + +const List = styled.ol` + margin: 0; + padding: 0; + list-style: none; +`; + +export default List; diff --git a/app/components/List/index.js b/app/components/List/index.js new file mode 100644 index 000000000..01a191572 --- /dev/null +++ b/app/components/List/index.js @@ -0,0 +1,3 @@ +// @flow +import List from './List'; +export default List; diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index db92804b7..99ffd5ce9 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -1,7 +1,13 @@ // @flow import * as React from 'react'; import { observer, inject } from 'mobx-react'; -import { ProfileIcon, SettingsIcon, CodeIcon, UserIcon } from 'outline-icons'; +import { + ProfileIcon, + SettingsIcon, + CodeIcon, + UserIcon, + LinkIcon, +} from 'outline-icons'; import Flex from 'shared/components/Flex'; import Sidebar, { Section } from './Sidebar'; @@ -51,6 +57,9 @@ class SettingsSidebar extends React.Component { }> Users + }> + Share Links + } diff --git a/app/index.js b/app/index.js index 394ee5be4..57e36b744 100644 --- a/app/index.js +++ b/app/index.js @@ -23,6 +23,7 @@ import Search from 'scenes/Search'; import Settings from 'scenes/Settings'; import Users from 'scenes/Settings/Users'; import Slack from 'scenes/Settings/Slack'; +import Shares from 'scenes/Settings/Shares'; import Tokens from 'scenes/Settings/Tokens'; import SlackAuth from 'scenes/SlackAuth'; import ErrorAuth from 'scenes/ErrorAuth'; @@ -77,6 +78,7 @@ if (element) { + { + componentDidMount() { + this.props.shares.fetchPage({ limit: 100 }); + } + + render() { + const { shares } = this.props; + + return ( + + +

Share Links

+ + {shares.data.map(share => ( + + Created{' '} + {' '} + ago by {share.createdBy.name} + + } + actions={ + + + + {' '} + + + } + /> + ))} + +
+ ); + } +} + +export default inject('shares')(Shares); diff --git a/app/stores/SharesStore.js b/app/stores/SharesStore.js new file mode 100644 index 000000000..9086c60ad --- /dev/null +++ b/app/stores/SharesStore.js @@ -0,0 +1,43 @@ +// @flow +import { observable, action, runInAction } from 'mobx'; +import invariant from 'invariant'; +import { client } from 'utils/ApiClient'; +import type { Share, PaginationParams } from 'types'; + +class SharesStore { + @observable data: Share[] = []; + @observable isFetching: boolean = false; + @observable isSaving: boolean = false; + + @action + fetchPage = async (options: ?PaginationParams): Promise<*> => { + this.isFetching = true; + + try { + const res = await client.post('/shares.list', options); + invariant(res && res.data, 'Data should be available'); + const { data } = res; + + runInAction('fetchShares', () => { + this.data = data; + }); + } catch (e) { + console.error('Something went wrong'); + } + this.isFetching = false; + }; + + @action + deleteShare = async (id: string) => { + try { + await client.post('/shares.delete', { id }); + runInAction('deleteShare', () => { + this.fetchPage(); + }); + } catch (e) { + console.error('Something went wrong'); + } + }; +} + +export default SharesStore; diff --git a/app/stores/index.js b/app/stores/index.js index f175941d5..68d49ca9c 100644 --- a/app/stores/index.js +++ b/app/stores/index.js @@ -3,6 +3,7 @@ import AuthStore from './AuthStore'; import UiStore from './UiStore'; import ErrorsStore from './ErrorsStore'; import DocumentsStore from './DocumentsStore'; +import SharesStore from './SharesStore'; const ui = new UiStore(); const errors = new ErrorsStore(); @@ -12,6 +13,7 @@ const stores = { ui, errors, documents: new DocumentsStore({ ui, errors }), + shares: new SharesStore(), }; export default stores; diff --git a/app/types/index.js b/app/types/index.js index 5c5a4dad0..4fff4c099 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -9,6 +9,15 @@ export type User = { isSuspended?: boolean, }; +export type Share = { + id: string, + url: string, + documentTitle: string, + createdBy: User, + createdAt: string, + updatedAt: string, +}; + export type Team = { id: string, name: string, diff --git a/server/emails/components/Button.js b/server/emails/components/Button.js index fbdb1af37..6b81faa55 100644 --- a/server/emails/components/Button.js +++ b/server/emails/components/Button.js @@ -15,5 +15,9 @@ export default (props: Props) => { cursor: 'pointer', }; - return {props.children}; + return ( + + {props.children} + + ); }; diff --git a/server/presenters/share.js b/server/presenters/share.js index 30977cde1..c0a06e9e9 100644 --- a/server/presenters/share.js +++ b/server/presenters/share.js @@ -5,9 +5,9 @@ import { presentUser } from '.'; function present(ctx: Object, share: Share) { return { id: share.id, - user: presentUser(ctx, share.user), documentTitle: share.document.title, url: `${process.env.URL}/share/${share.id}`, + createdBy: presentUser(ctx, share.user), createdAt: share.createdAt, updatedAt: share.updatedAt, }; From 47fb9680096299ca5285e215678323b844b3df0d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 23 May 2018 22:09:14 -0700 Subject: [PATCH 08/23] Ability to revoke, ShareMenu --- app/components/List/Item.js | 2 +- app/menus/ShareMenu.js | 54 +++++++++++++++++++ .../Settings/components => menus}/UserMenu.js | 38 +++++++------ app/scenes/Settings/Shares.js | 31 ++--------- app/scenes/Settings/Users.js | 6 +-- .../Settings/components/ShareListItem.js | 31 +++++++++++ app/stores/SharesStore.js | 22 +++++--- app/types/index.js | 1 + server/api/shares.js | 3 +- server/policies/share.js | 2 +- server/presenters/share.js | 1 + 11 files changed, 129 insertions(+), 62 deletions(-) create mode 100644 app/menus/ShareMenu.js rename app/{scenes/Settings/components => menus}/UserMenu.js (69%) create mode 100644 app/scenes/Settings/components/ShareListItem.js diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 7fd7bfaad..a3badbc12 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -50,7 +50,7 @@ const Subtitle = styled.p` `; const Actions = styled.div` - align-self: flex-end; + align-self: center; `; export default ListItem; diff --git a/app/menus/ShareMenu.js b/app/menus/ShareMenu.js new file mode 100644 index 000000000..67c3b18f0 --- /dev/null +++ b/app/menus/ShareMenu.js @@ -0,0 +1,54 @@ +// @flow +import * as React from 'react'; +import { withRouter } from 'react-router-dom'; +import { inject } from 'mobx-react'; +import { MoreIcon } from 'outline-icons'; + +import { Share } from 'types'; +import CopyToClipboard from 'components/CopyToClipboard'; +import SharesStore from 'stores/SharesStore'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +type Props = { + label?: React.Node, + onOpen?: () => *, + onClose?: () => *, + history: Object, + shares: SharesStore, + share: Share, +}; + +class ShareMenu extends React.Component { + onGoToDocument = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.props.history.push(this.props.share.documentUrl); + }; + + onRevoke = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.props.shares.revoke(this.props.share); + }; + + render() { + const { share, label, onOpen, onClose } = this.props; + + return ( + } + onOpen={onOpen} + onClose={onClose} + > + + Copy link + + + Go to document + +
+ Revoke link +
+ ); + } +} + +export default withRouter(inject('shares')(ShareMenu)); diff --git a/app/scenes/Settings/components/UserMenu.js b/app/menus/UserMenu.js similarity index 69% rename from app/scenes/Settings/components/UserMenu.js rename to app/menus/UserMenu.js index f833f921f..a3b90f354 100644 --- a/app/scenes/Settings/components/UserMenu.js +++ b/app/menus/UserMenu.js @@ -61,29 +61,27 @@ class UserMenu extends React.Component { const { user } = this.props; return ( - - }> - {!user.isSuspended && - (user.isAdmin ? ( - - Make {user.name} a member… - - ) : ( - - Make {user.name} an admin… - - ))} - {user.isSuspended ? ( - - Activate account + }> + {!user.isSuspended && + (user.isAdmin ? ( + + Make {user.name} a member… ) : ( - - Suspend account… + + Make {user.name} an admin… - )} - - + ))} + {user.isSuspended ? ( + + Activate account + + ) : ( + + Suspend account… + + )} + ); } } diff --git a/app/scenes/Settings/Shares.js b/app/scenes/Settings/Shares.js index 7b2b929a3..28139353c 100644 --- a/app/scenes/Settings/Shares.js +++ b/app/scenes/Settings/Shares.js @@ -2,12 +2,9 @@ import * as React from 'react'; import { observer, inject } from 'mobx-react'; import SharesStore from 'stores/SharesStore'; -import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; -import CopyToClipboard from 'components/CopyToClipboard'; -import Button from 'components/Button'; +import ShareListItem from './components/ShareListItem'; import List from 'components/List'; -import ListItem from 'components/List/Item'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; @@ -29,30 +26,8 @@ class Shares extends React.Component {

Share Links

- {shares.data.map(share => ( - - Created{' '} - {' '} - ago by {share.createdBy.name} - - } - actions={ - - - - {' '} - - - } - /> + {shares.orderedData.map(share => ( + ))} diff --git a/app/scenes/Settings/Users.js b/app/scenes/Settings/Users.js index e17b7cae1..fa63ca17c 100644 --- a/app/scenes/Settings/Users.js +++ b/app/scenes/Settings/Users.js @@ -7,17 +7,15 @@ import Flex from 'shared/components/Flex'; import Avatar from 'components/Avatar'; import { color } from 'shared/styles/constants'; +import UserMenu from 'menus/UserMenu'; import AuthStore from 'stores/AuthStore'; -import ErrorsStore from 'stores/ErrorsStore'; import UsersStore from 'stores/UsersStore'; import CenteredContent from 'components/CenteredContent'; import LoadingPlaceholder from 'components/LoadingPlaceholder'; import PageTitle from 'components/PageTitle'; -import UserMenu from './components/UserMenu'; type Props = { auth: AuthStore, - errors: ErrorsStore, users: UsersStore, }; @@ -105,4 +103,4 @@ const Badge = styled.span` font-weight: normal; `; -export default inject('auth', 'errors', 'users')(Users); +export default inject('auth', 'users')(Users); diff --git a/app/scenes/Settings/components/ShareListItem.js b/app/scenes/Settings/components/ShareListItem.js new file mode 100644 index 000000000..c78b950c1 --- /dev/null +++ b/app/scenes/Settings/components/ShareListItem.js @@ -0,0 +1,31 @@ +// @flow +import * as React from 'react'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import ShareMenu from 'menus/ShareMenu'; +import ListItem from 'components/List/Item'; +import type { Share } from '../../../types'; + +type Props = { + share: Share, +}; + +const ShareListItem = ({ share }: Props) => { + return ( + + Shared{' '} + {' '} + ago by {share.createdBy.name} + + } + actions={} + /> + ); +}; + +export default ShareListItem; diff --git a/app/stores/SharesStore.js b/app/stores/SharesStore.js index 9086c60ad..dcabd8837 100644 --- a/app/stores/SharesStore.js +++ b/app/stores/SharesStore.js @@ -1,14 +1,20 @@ // @flow -import { observable, action, runInAction } from 'mobx'; +import _ from 'lodash'; +import { observable, action, runInAction, ObservableMap, computed } from 'mobx'; import invariant from 'invariant'; import { client } from 'utils/ApiClient'; import type { Share, PaginationParams } from 'types'; class SharesStore { - @observable data: Share[] = []; + @observable data: Map = new ObservableMap([]); @observable isFetching: boolean = false; @observable isSaving: boolean = false; + @computed + get orderedData(): Share[] { + return _.sortBy(this.data.values(), 'createdAt').reverse(); + } + @action fetchPage = async (options: ?PaginationParams): Promise<*> => { this.isFetching = true; @@ -19,7 +25,9 @@ class SharesStore { const { data } = res; runInAction('fetchShares', () => { - this.data = data; + data.forEach(share => { + this.data.set(share.id, share); + }); }); } catch (e) { console.error('Something went wrong'); @@ -28,11 +36,11 @@ class SharesStore { }; @action - deleteShare = async (id: string) => { + revoke = async (share: Share) => { try { - await client.post('/shares.delete', { id }); - runInAction('deleteShare', () => { - this.fetchPage(); + await client.post('/shares.delete', { id: share.id }); + runInAction('revoke', () => { + this.data.delete(share.id); }); } catch (e) { console.error('Something went wrong'); diff --git a/app/types/index.js b/app/types/index.js index 4fff4c099..784a8e433 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -13,6 +13,7 @@ export type Share = { id: string, url: string, documentTitle: string, + documentUrl: string, createdBy: User, createdAt: string, updatedAt: string, diff --git a/server/api/shares.js b/server/api/shares.js index 16cfd7edc..471ac0db8 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -65,8 +65,9 @@ router.post('shares.delete', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); + const user = ctx.state.user; const share = await Share.findById(id); - authorize(ctx.state.user, 'delete', share); + authorize(user, 'delete', share); await share.destroy(); diff --git a/server/policies/share.js b/server/policies/share.js index 9872103d8..3e2d1121b 100644 --- a/server/policies/share.js +++ b/server/policies/share.js @@ -9,7 +9,7 @@ allow(User, ['read'], Share, (user, share) => user.teamId === share.teamId); allow(User, ['update'], Share, (user, share) => false); allow(User, ['delete'], Share, (user, share) => { if (!share || user.teamId !== share.teamId) return false; - if (user.id === share.userId) return false; + if (user.id === share.userId) return true; if (user.isAdmin) return true; throw new AdminRequiredError(); }); diff --git a/server/presenters/share.js b/server/presenters/share.js index c0a06e9e9..1de3825ca 100644 --- a/server/presenters/share.js +++ b/server/presenters/share.js @@ -6,6 +6,7 @@ function present(ctx: Object, share: Share) { return { id: share.id, documentTitle: share.document.title, + documentUrl: share.document.getUrl(), url: `${process.env.URL}/share/${share.id}`, createdBy: presentUser(ctx, share.user), createdAt: share.createdAt, From aeb97ddcae79df53a8b546e362930832b745c17a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 23 May 2018 22:55:01 -0700 Subject: [PATCH 09/23] Filter shares.list endpoint by admin --- server/api/shares.js | 7 ++++++- server/api/shares.test.js | 26 ++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/server/api/shares.js b/server/api/shares.js index 471ac0db8..b50f5c1a4 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -5,6 +5,7 @@ import pagination from './middlewares/pagination'; import { presentShare } from '../presenters'; import { Document, User, Share } from '../models'; import policy from '../policies'; +import { constants } from 'os'; const { authorize } = policy; const router = new Router(); @@ -14,8 +15,12 @@ router.post('shares.list', auth(), pagination(), async ctx => { if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; + const where = { teamId: user.teamId, userId: user.id }; + + if (user.isAdmin) delete where.userId; + const shares = await Share.findAll({ - where: { teamId: user.teamId }, + where, order: [[sort, direction]], include: [ { diff --git a/server/api/shares.test.js b/server/api/shares.test.js index c9d78f851..1229e8ca4 100644 --- a/server/api/shares.test.js +++ b/server/api/shares.test.js @@ -10,12 +10,17 @@ beforeEach(flushdb); afterAll(server.close); describe('#shares.list', async () => { - it('should return a list of shares', async () => { + it('should only return shares created by user', async () => { const { user, document } = await seed(); - const share = await buildShare({ + await buildShare({ documentId: document.id, teamId: user.teamId, }); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + userId: user.id, + }); const res = await server.post('/api/shares.list', { body: { token: user.getJwtToken() }, }); @@ -27,6 +32,23 @@ describe('#shares.list', async () => { expect(body.data[0].documentTitle).toBe(document.title); }); + it('admins should only return shares created by all users', async () => { + const { admin, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: admin.teamId, + }); + const res = await server.post('/api/shares.list', { + body: { token: admin.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(share.id); + expect(body.data[0].documentTitle).toBe(document.title); + }); + it('should require authentication', async () => { const res = await server.post('/api/shares.list'); const body = await res.json(); From 7eea1a90afb1314278f2f92fc973b2ef71033298 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 23 May 2018 23:09:20 -0700 Subject: [PATCH 10/23] One share link per user, per doc --- server/api/shares.js | 12 +++++++----- server/api/shares.test.js | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/server/api/shares.js b/server/api/shares.js index b50f5c1a4..49bbe84fe 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -5,7 +5,6 @@ import pagination from './middlewares/pagination'; import { presentShare } from '../presenters'; import { Document, User, Share } from '../models'; import policy from '../policies'; -import { constants } from 'os'; const { authorize } = policy; const router = new Router(); @@ -53,11 +52,14 @@ router.post('shares.create', auth(), async ctx => { const document = await Document.findById(id); authorize(user, 'share', document); - const share = await Share.create({ - documentId: document.id, - userId: user.id, - teamId: user.teamId, + const [share, created] = await Share.findOrCreate({ + where: { + documentId: document.id, + userId: user.id, + teamId: user.teamId, + }, }); + console.log('created', created); share.user = user; share.document = document; diff --git a/server/api/shares.test.js b/server/api/shares.test.js index 1229e8ca4..30df40f64 100644 --- a/server/api/shares.test.js +++ b/server/api/shares.test.js @@ -70,6 +70,22 @@ describe('#shares.create', async () => { expect(body.data.documentTitle).toBe(document.title); }); + it('should return existing share link for document and user', 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.create', { + body: { token: user.getJwtToken(), id: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toBe(share.id); + }); + it('should require authentication', async () => { const { document } = await seed(); const res = await server.post('/api/shares.create', { From e538df0df31e6f17dbdde48291056d37b30f6d2f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 23 May 2018 23:59:00 -0700 Subject: [PATCH 11/23] Filter private info from public shares --- app/models/Document.js | 1 - app/scenes/Document/Document.js | 28 ++++++++++-------- app/scenes/DocumentShare/DocumentShare.js | 2 +- app/types/index.js | 1 - server/api/documents.js | 6 ++-- server/api/shares.js | 4 +-- server/presenters/collection.js | 3 +- server/presenters/document.js | 36 +++++++++++++---------- 8 files changed, 42 insertions(+), 39 deletions(-) diff --git a/app/models/Document.js b/app/models/Document.js index 3e4af0177..a38850bea 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -32,7 +32,6 @@ class Document extends BaseModel { id: string; team: string; emoji: string; - private: boolean = false; starred: boolean = false; pinned: boolean = false; text: string = ''; diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index 658585461..c2a0df021 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -234,6 +234,7 @@ class DocumentScene extends React.Component { const document = this.document; const titleFromState = location.state ? location.state.title : ''; const titleText = document ? document.title : titleFromState; + const isShare = match.params.shareId; if (this.notFound) { return ; @@ -273,19 +274,20 @@ class DocumentScene extends React.Component { readOnly={!this.isEditing} /> - {document && ( - - )} + {document && + !isShare && ( + + )} )}
diff --git a/app/scenes/DocumentShare/DocumentShare.js b/app/scenes/DocumentShare/DocumentShare.js index a0ecaf3fb..c5d0901e8 100644 --- a/app/scenes/DocumentShare/DocumentShare.js +++ b/app/scenes/DocumentShare/DocumentShare.js @@ -34,7 +34,7 @@ class DocumentShare extends React.Component { The link below allows anyone to access a read-only version of the document {document.title}. You can revoke this link - at any point in the future. + in settings at any time. { const { id, shareId } = ctx.body; ctx.assertPresent(id || shareId, 'id or shareId is required'); + const isPublic = !!shareId; let document; + if (shareId) { const share = await Share.findById(shareId, { include: [ @@ -172,8 +174,6 @@ router.post('documents.info', auth({ required: false }), async ctx => { }, ], }); - - // TODO: REMOVE COLLECTION AND COLLABORATOR INFO document = share.document; } else { document = await Document.findById(id); @@ -181,7 +181,7 @@ router.post('documents.info', auth({ required: false }), async ctx => { } ctx.body = { - data: await presentDocument(ctx, document), + data: await presentDocument(ctx, document, { isPublic }), }; }); diff --git a/server/api/shares.js b/server/api/shares.js index 49bbe84fe..0a3e38985 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -52,14 +52,14 @@ router.post('shares.create', auth(), async ctx => { const document = await Document.findById(id); authorize(user, 'share', document); - const [share, created] = await Share.findOrCreate({ + const [share] = await Share.findOrCreate({ where: { documentId: document.id, userId: user.id, teamId: user.teamId, }, }); - console.log('created', created); + share.user = user; share.document = document; diff --git a/server/presenters/collection.js b/server/presenters/collection.js index 965aad8ca..5b9af99da 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -42,8 +42,7 @@ async function present(ctx: Object, collection: Collection) { if (collection.documents) { data.recentDocuments = await Promise.all( collection.documents.map( - async document => - await presentDocument(ctx, document, { includeCollaborators: true }) + async document => await presentDocument(ctx, document) ) ); } diff --git a/server/presenters/document.js b/server/presenters/document.js index 0e7158119..37534e633 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -8,12 +8,12 @@ import presentCollection from './collection'; const Op = Sequelize.Op; type Options = { - includeCollaborators?: boolean, + isPublic?: boolean, }; async function present(ctx: Object, document: Document, options: ?Options) { options = { - includeCollaborators: true, + isPublic: false, ...options, }; ctx.cache.set(document.id, document); @@ -27,39 +27,43 @@ async function present(ctx: Object, document: Document, options: ?Options) { id: document.id, url: document.getUrl(), urlId: document.urlId, - private: document.private, title: document.title, text: document.text, emoji: document.emoji, createdAt: document.createdAt, - createdBy: presentUser(ctx, document.createdBy), + createdBy: undefined, updatedAt: document.updatedAt, - updatedBy: presentUser(ctx, document.updatedBy), + updatedBy: undefined, publishedAt: document.publishedAt, firstViewedAt: undefined, lastViewedAt: undefined, team: document.teamId, collaborators: [], starred: !!(document.starred && document.starred.length), - pinned: !!document.pinnedById, revision: document.revisionCount, - collectionId: document.atlasId, + pinned: undefined, + collectionId: undefined, collaboratorCount: undefined, collection: undefined, views: undefined, }; - if (document.private && document.collection) { - data.collection = await presentCollection(ctx, document.collection); - } + if (!options.isPublic) { + data.pinned = !!document.pinnedById; + data.collectionId = document.atlasId; + data.createdBy = presentUser(ctx, document.createdBy); + data.updatedBy = presentUser(ctx, document.updatedBy); - if (document.views && document.views.length === 1) { - data.views = document.views[0].count; - data.firstViewedAt = document.views[0].createdAt; - data.lastViewedAt = document.views[0].updatedAt; - } + if (document.collection) { + data.collection = await presentCollection(ctx, document.collection); + } + + if (document.views && document.views.length === 1) { + data.views = document.views[0].count; + data.firstViewedAt = document.views[0].createdAt; + data.lastViewedAt = document.views[0].updatedAt; + } - if (options.includeCollaborators) { // This could be further optimized by using ctx.cache data.collaborators = await User.findAll({ where: { From c060a5c798b4782b3d919714d5d5088e51fdec53 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 21:19:38 -0700 Subject: [PATCH 12/23] id -> documentId --- app/models/Document.js | 5 +---- server/api/shares.js | 8 ++++---- server/api/shares.test.js | 8 ++++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/models/Document.js b/app/models/Document.js index a38850bea..ba17afbe6 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -43,8 +43,6 @@ class Document extends BaseModel { views: number; revision: number; - data: Object; - /* Computed */ @computed @@ -104,7 +102,7 @@ class Document extends BaseModel { @action share = async () => { try { - const res = await client.post('/shares.create', { id: this.id }); + const res = await client.post('/shares.create', { documentId: this.id }); invariant(res && res.data, 'Document API response should be available'); this.shareUrl = res.data.url; @@ -289,7 +287,6 @@ class Document extends BaseModel { data.emoji = emoji; } if (dirty) this.hasPendingChanges = true; - this.data = data; extendObservable(this, data); } diff --git a/server/api/shares.js b/server/api/shares.js index 0a3e38985..ce2fb9e11 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -45,16 +45,16 @@ router.post('shares.list', auth(), pagination(), async ctx => { }); router.post('shares.create', auth(), async ctx => { - const { id } = ctx.body; - ctx.assertPresent(id, 'id is required'); + const { documentId } = ctx.body; + ctx.assertPresent(documentId, 'documentId is required'); const user = ctx.state.user; - const document = await Document.findById(id); + const document = await Document.findById(documentId); authorize(user, 'share', document); const [share] = await Share.findOrCreate({ where: { - documentId: document.id, + documentId, userId: user.id, teamId: user.teamId, }, diff --git a/server/api/shares.test.js b/server/api/shares.test.js index 30df40f64..730007779 100644 --- a/server/api/shares.test.js +++ b/server/api/shares.test.js @@ -62,7 +62,7 @@ describe('#shares.create', async () => { it('should allow creating a share record for document', async () => { const { user, document } = await seed(); const res = await server.post('/api/shares.create', { - body: { token: user.getJwtToken(), id: document.id }, + body: { token: user.getJwtToken(), documentId: document.id }, }); const body = await res.json(); @@ -78,7 +78,7 @@ describe('#shares.create', async () => { userId: user.id, }); const res = await server.post('/api/shares.create', { - body: { token: user.getJwtToken(), id: document.id }, + body: { token: user.getJwtToken(), documentId: document.id }, }); const body = await res.json(); @@ -89,7 +89,7 @@ describe('#shares.create', async () => { it('should require authentication', async () => { const { document } = await seed(); const res = await server.post('/api/shares.create', { - body: { id: document.id }, + body: { documentId: document.id }, }); const body = await res.json(); @@ -101,7 +101,7 @@ describe('#shares.create', async () => { const { document } = await seed(); const user = await buildUser(); const res = await server.post('/api/shares.create', { - body: { token: user.getJwtToken(), id: document.id }, + body: { token: user.getJwtToken(), documentId: document.id }, }); expect(res.status).toEqual(403); }); From 2c719df32e881bc57e55196fee4a426c1c577c95 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 21:21:45 -0700 Subject: [PATCH 13/23] Close share dialog on copy link --- app/components/Modals/Modals.js | 2 +- app/scenes/DocumentShare/DocumentShare.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/components/Modals/Modals.js b/app/components/Modals/Modals.js index d9a2c23c2..4402415c0 100644 --- a/app/components/Modals/Modals.js +++ b/app/components/Modals/Modals.js @@ -46,7 +46,7 @@ class Modals extends React.Component { - + diff --git a/app/scenes/DocumentShare/DocumentShare.js b/app/scenes/DocumentShare/DocumentShare.js index c5d0901e8..4ddace7df 100644 --- a/app/scenes/DocumentShare/DocumentShare.js +++ b/app/scenes/DocumentShare/DocumentShare.js @@ -10,6 +10,7 @@ import Document from 'models/Document'; type Props = { document: Document, + onCopyLink: () => *, }; @observer @@ -23,7 +24,11 @@ class DocumentShare extends React.Component { handleCopied = () => { this.isCopied = true; - this.timeout = setTimeout(() => (this.isCopied = false), 3000); + + this.timeout = setTimeout(() => { + this.isCopied = false; + this.props.onCopyLink(); + }, 2000); }; render() { From e2144051dfc676e4cf196e665468f84a76f4d863 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 22:15:36 -0700 Subject: [PATCH 14/23] Fixed returning sensitive data in documents.info --- app/stores/UiStore.js | 2 +- server/api/documents.js | 8 +++++--- server/api/documents.test.js | 26 ++++++++++++++++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index dda930c1e..109ee725e 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -30,7 +30,7 @@ class UiStore { this.activeDocumentId = document.id; if (document.publishedAt) { - this.activeCollectionId = document.collection.id; + this.activeCollectionId = document.collectionId; } }; diff --git a/server/api/documents.js b/server/api/documents.js index 30d7e7fc5..099751d77 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -10,7 +10,7 @@ import events from '../events'; import policy from '../policies'; const Op = Sequelize.Op; -const { authorize } = policy; +const { authorize, cannot } = policy; const router = new Router(); router.post('documents.list', auth(), pagination(), async ctx => { @@ -161,7 +161,7 @@ router.post('documents.info', auth({ required: false }), async ctx => { const { id, shareId } = ctx.body; ctx.assertPresent(id || shareId, 'id or shareId is required'); - const isPublic = !!shareId; + const user = ctx.state.user; let document; if (shareId) { @@ -177,9 +177,11 @@ router.post('documents.info', auth({ required: false }), async ctx => { document = share.document; } else { document = await Document.findById(id); - authorize(ctx.state.user, 'read', document); + authorize(user, 'read', document); } + const isPublic = cannot(user, 'read', document); + ctx.body = { data: await presentDocument(ctx, document, { isPublic }), }; diff --git a/server/api/documents.test.js b/server/api/documents.test.js index ba5ef342c..e38f1a2c3 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -36,8 +36,27 @@ describe('#documents.info', async () => { expect(body.data.id).toEqual(document.id); }); - it('should return documents from shareId', async () => { - const { user, document } = await seed(); + it('should return redacted documents from shareId without token', async () => { + const { document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: document.teamId, + }); + + const res = await server.post('/api/documents.info', { + body: { shareId: share.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(document.id); + expect(body.data.collection).toEqual(undefined); + expect(body.data.createdBy).toEqual(undefined); + expect(body.data.updatedBy).toEqual(undefined); + }); + + it('should return documents from shareId with token', async () => { + const { user, document, collection } = await seed(); const share = await buildShare({ documentId: document.id, teamId: document.teamId, @@ -50,6 +69,9 @@ describe('#documents.info', async () => { expect(res.status).toEqual(200); expect(body.data.id).toEqual(document.id); + expect(body.data.collection.id).toEqual(collection.id); + expect(body.data.createdBy.id).toEqual(user.id); + expect(body.data.updatedBy.id).toEqual(user.id); }); }); From 54e5037aafcf288cf215dc3bb5fdfc9f2db0b7f9 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 23:07:44 -0700 Subject: [PATCH 15/23] Cleanup user list --- app/scenes/Settings/Users.js | 84 +++---------------- .../Settings/components/UserListItem.js | 45 ++++++++++ app/stores/UsersStore.js | 2 - 3 files changed, 58 insertions(+), 73 deletions(-) create mode 100644 app/scenes/Settings/components/UserListItem.js diff --git a/app/scenes/Settings/Users.js b/app/scenes/Settings/Users.js index fa63ca17c..c78d78797 100644 --- a/app/scenes/Settings/Users.js +++ b/app/scenes/Settings/Users.js @@ -2,17 +2,13 @@ import * as React from 'react'; import invariant from 'invariant'; import { observer, inject } from 'mobx-react'; -import styled from 'styled-components'; -import Flex from 'shared/components/Flex'; -import Avatar from 'components/Avatar'; -import { color } from 'shared/styles/constants'; -import UserMenu from 'menus/UserMenu'; import AuthStore from 'stores/AuthStore'; import UsersStore from 'stores/UsersStore'; import CenteredContent from 'components/CenteredContent'; -import LoadingPlaceholder from 'components/LoadingPlaceholder'; import PageTitle from 'components/PageTitle'; +import UserListItem from './components/UserListItem'; +import List from 'components/List'; type Props = { auth: AuthStore, @@ -26,7 +22,8 @@ class Users extends React.Component { } render() { - const currentUser = this.props.auth.user; + const { users, auth } = this.props; + const currentUser = auth.user; invariant(currentUser, 'User should exist'); return ( @@ -34,73 +31,18 @@ class Users extends React.Component {

Users

- {!this.props.users.isLoaded ? ( - - {this.props.users.data && ( - - {this.props.users.data.map(user => ( - - - - - {user.name} {user.email && `(${user.email})`} - {user.isAdmin && ( - Admin - )} - {user.isSuspended && Suspended} - - - - {currentUser.id !== user.id && } - - - ))} - - )} - - ) : ( - - )} + + {users.data.map(user => ( + + ))} + ); } } -const UserList = styled(Flex)` - border: 1px solid ${color.smoke}; - border-radius: 4px; - - margin-top: 20px; - margin-bottom: 40px; -`; - -const User = styled(Flex)` - padding: 10px; - border-bottom: 1px solid ${color.smoke}; - font-size: 15px; - - &:last-child { - border-bottom: none; - } -`; - -const UserDetails = styled(Flex)` - opacity: ${({ suspended }) => (suspended ? 0.5 : 1)}; -`; - -const UserName = styled.span` - padding-left: 8px; -`; - -const Badge = styled.span` - margin-left: 10px; - padding: 2px 6px 3px; - background-color: ${({ admin }) => (admin ? color.primary : color.smokeDark)}; - color: ${({ admin }) => (admin ? color.white : color.text)}; - border-radius: 2px; - font-size: 11px; - text-transform: uppercase; - font-weight: normal; -`; - export default inject('auth', 'users')(Users); diff --git a/app/scenes/Settings/components/UserListItem.js b/app/scenes/Settings/components/UserListItem.js new file mode 100644 index 000000000..c7de8a56b --- /dev/null +++ b/app/scenes/Settings/components/UserListItem.js @@ -0,0 +1,45 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { color } from 'shared/styles/constants'; + +import UserMenu from 'menus/UserMenu'; +import Avatar from 'components/Avatar'; +import ListItem from 'components/List/Item'; +import type { User } from '../../../types'; + +type Props = { + user: User, + isCurrentUser: boolean, +}; + +const UserListItem = ({ user, isCurrentUser }: Props) => { + return ( + } + subtitle={ + + {user.username ? user.username : user.email} + {user.isAdmin && Admin} + {user.isSuspended && Suspended} + + } + actions={isCurrentUser ? undefined : } + /> + ); +}; + +const Badge = styled.span` + margin-left: 10px; + padding: 2px 6px 3px; + background-color: ${({ admin }) => (admin ? color.primary : color.smokeDark)}; + color: ${({ admin }) => (admin ? color.white : color.text)}; + border-radius: 2px; + font-size: 11px; + text-transform: uppercase; + font-weight: normal; +`; + +export default UserListItem; diff --git a/app/stores/UsersStore.js b/app/stores/UsersStore.js index d9df0b832..5b4945dd7 100644 --- a/app/stores/UsersStore.js +++ b/app/stores/UsersStore.js @@ -6,7 +6,6 @@ import type { User, PaginationParams } from 'types'; class UsersStore { @observable data: User[] = []; - @observable isLoaded: boolean = false; @observable isSaving: boolean = false; @action @@ -22,7 +21,6 @@ class UsersStore { } catch (e) { console.error('Something went wrong'); } - this.isLoaded = false; }; @action From 6df753d962b27de6eebb4828bd1acc0947236433 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 23:23:05 -0700 Subject: [PATCH 16/23] Team users -> Team members --- app/components/Sidebar/Settings.js | 4 ++-- app/index.js | 8 ++++++-- app/scenes/Settings/{Users.js => Members.js} | 8 ++++---- 3 files changed, 12 insertions(+), 8 deletions(-) rename app/scenes/Settings/{Users.js => Members.js} (86%) diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 99ffd5ce9..2f20c6d70 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -54,8 +54,8 @@ class SettingsSidebar extends React.Component {
Team
- }> - Users + }> + Members }> Share Links diff --git a/app/index.js b/app/index.js index 57e36b744..f90af9838 100644 --- a/app/index.js +++ b/app/index.js @@ -21,7 +21,7 @@ import Collection from 'scenes/Collection'; import Document from 'scenes/Document'; import Search from 'scenes/Search'; import Settings from 'scenes/Settings'; -import Users from 'scenes/Settings/Users'; +import Members from 'scenes/Settings/Members'; import Slack from 'scenes/Settings/Slack'; import Shares from 'scenes/Settings/Shares'; import Tokens from 'scenes/Settings/Tokens'; @@ -77,7 +77,11 @@ if (element) { - + { +class Members extends React.Component { componentDidMount() { this.props.users.fetchPage({ limit: 100 }); } @@ -28,8 +28,8 @@ class Users extends React.Component { return ( - -

Users

+ +

Members

{users.data.map(user => ( @@ -45,4 +45,4 @@ class Users extends React.Component { } } -export default inject('auth', 'users')(Users); +export default inject('auth', 'users')(Members); From 214f2505a5b6222552381b5ff593f09c9773bb80 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 23:39:17 -0700 Subject: [PATCH 17/23] API tokens to standard list item --- app/scenes/Settings/Tokens.js | 46 ++++++------------- app/scenes/Settings/components/ApiToken.js | 43 ----------------- .../Settings/components/TokenListItem.js | 28 +++++++++++ app/types/index.js | 2 +- 4 files changed, 43 insertions(+), 76 deletions(-) delete mode 100644 app/scenes/Settings/components/ApiToken.js create mode 100644 app/scenes/Settings/components/TokenListItem.js diff --git a/app/scenes/Settings/Tokens.js b/app/scenes/Settings/Tokens.js index a3b30afa1..3379b2cb6 100644 --- a/app/scenes/Settings/Tokens.js +++ b/app/scenes/Settings/Tokens.js @@ -3,17 +3,15 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; -import ApiToken from './components/ApiToken'; import ApiKeysStore from 'stores/ApiKeysStore'; -import { color } from 'shared/styles/constants'; import Button from 'components/Button'; import Input from 'components/Input'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import HelpText from 'components/HelpText'; -import Subheading from 'components/Subheading'; +import List from 'components/List'; +import TokenListItem from './components/TokenListItem'; type Props = { apiKeys: ApiKeysStore, @@ -46,29 +44,23 @@ class Tokens extends React.Component {

API Tokens

- {hasApiKeys && [ - Your tokens, - - - {apiKeys.data.map(key => ( - - ))} - -
, - Create a token, - ]} - You can create unlimited personal API tokens to hack on your wiki. Learn more in the API documentation. + {hasApiKeys && ( + + {apiKeys.data.map(token => ( + + ))} + + )} +
{ } } -const Table = styled.table` - margin-bottom: 30px; - width: 100%; - - td { - margin-right: 20px; - color: ${color.slate}; - } -`; - export default inject('apiKeys')(Tokens); diff --git a/app/scenes/Settings/components/ApiToken.js b/app/scenes/Settings/components/ApiToken.js deleted file mode 100644 index 491a90364..000000000 --- a/app/scenes/Settings/components/ApiToken.js +++ /dev/null @@ -1,43 +0,0 @@ -// @flow -import * as React from 'react'; -import { observable } from 'mobx'; -import { observer } from 'mobx-react'; -import Button from 'components/Button'; - -type Props = { - id: string, - name: ?string, - secret: string, - onDelete: (id: string) => *, -}; - -@observer -class ApiToken extends React.Component { - @observable disabled: boolean; - - onClick = () => { - this.props.onDelete(this.props.id); - this.disabled = true; - }; - - render() { - const { name, secret } = this.props; - const { disabled } = this; - - return ( - - {name} - - {secret} - - - - - - ); - } -} - -export default ApiToken; diff --git a/app/scenes/Settings/components/TokenListItem.js b/app/scenes/Settings/components/TokenListItem.js new file mode 100644 index 000000000..926c43f6c --- /dev/null +++ b/app/scenes/Settings/components/TokenListItem.js @@ -0,0 +1,28 @@ +// @flow +import * as React from 'react'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import Button from 'components/Button'; +import ListItem from 'components/List/Item'; +import type { ApiKey } from '../../../types'; + +type Props = { + token: ApiKey, + onDelete: (tokenId: string) => *, +}; + +const TokenListItem = ({ token, onDelete }: Props) => { + return ( + {token.secret}} + actions={ + + } + /> + ); +}; + +export default TokenListItem; diff --git a/app/types/index.js b/app/types/index.js index 323413a19..2ef769b57 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -67,6 +67,6 @@ export type PaginationParams = { export type ApiKey = { id: string, - name: ?string, + name: string, secret: string, }; From 511aab5d3a4fe8f0ecd36fe17079a6ef7d32274b Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 23:49:46 -0700 Subject: [PATCH 18/23] Layout issue --- app/components/List/Item.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/List/Item.js b/app/components/List/Item.js index a3badbc12..4f19f0e52 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -32,6 +32,7 @@ const Wrapper = styled.li` const Image = styled.div` padding: 0 8px 0 0; + max-height: 40px; `; const Heading = styled.h2` From de54698408c708b531e6cd337cb4a5f15dbb63e5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 May 2018 23:50:40 -0700 Subject: [PATCH 19/23] :shirt: --- app/scenes/Settings/components/TokenListItem.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/scenes/Settings/components/TokenListItem.js b/app/scenes/Settings/components/TokenListItem.js index 926c43f6c..d4ad90d70 100644 --- a/app/scenes/Settings/components/TokenListItem.js +++ b/app/scenes/Settings/components/TokenListItem.js @@ -1,6 +1,5 @@ // @flow import * as React from 'react'; -import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; import Button from 'components/Button'; import ListItem from 'components/List/Item'; import type { ApiKey } from '../../../types'; From 0d7c943bcd655583f1d6c58065b3e311f89c2516 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 26 May 2018 10:38:44 -0700 Subject: [PATCH 20/23] Add link to manage shares from modal --- app/components/Modals/Modals.js | 2 +- app/scenes/DocumentShare/DocumentShare.js | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/components/Modals/Modals.js b/app/components/Modals/Modals.js index 4402415c0..d9a2c23c2 100644 --- a/app/components/Modals/Modals.js +++ b/app/components/Modals/Modals.js @@ -46,7 +46,7 @@ class Modals extends React.Component { - + diff --git a/app/scenes/DocumentShare/DocumentShare.js b/app/scenes/DocumentShare/DocumentShare.js index 4ddace7df..025e42580 100644 --- a/app/scenes/DocumentShare/DocumentShare.js +++ b/app/scenes/DocumentShare/DocumentShare.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; +import { Link } from 'react-router-dom'; import Input from 'components/Input'; import Button from 'components/Button'; import CopyToClipboard from 'components/CopyToClipboard'; @@ -9,8 +10,8 @@ import HelpText from 'components/HelpText'; import Document from 'models/Document'; type Props = { - document: Document, - onCopyLink: () => *, + document?: Document, + onSubmit: () => *, }; @observer @@ -27,19 +28,23 @@ class DocumentShare extends React.Component { this.timeout = setTimeout(() => { this.isCopied = false; - this.props.onCopyLink(); - }, 2000); + this.props.onSubmit(); + }, 1500); }; render() { - const { document } = this.props; + const { document, onSubmit } = this.props; + if (!document) return null; return (
- The link below allows anyone to access a read-only version of the - document {document.title}. You can revoke this link - in settings at any time. + 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.{' '} + + Manage share links + . Date: Sat, 26 May 2018 11:23:21 -0700 Subject: [PATCH 21/23] Handle non-existent shareId --- server/api/documents.js | 3 +++ server/api/documents.test.js | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/server/api/documents.js b/server/api/documents.js index 099751d77..5cf4a3b27 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -174,6 +174,9 @@ router.post('documents.info', auth({ required: false }), async ctx => { }, ], }); + if (!share) { + throw new InvalidRequestError('Document could not be found for shareId'); + } document = share.document; } else { document = await Document.findById(id); diff --git a/server/api/documents.test.js b/server/api/documents.test.js index e38f1a2c3..86aa35335 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -73,6 +73,13 @@ describe('#documents.info', async () => { expect(body.data.createdBy.id).toEqual(user.id); expect(body.data.updatedBy.id).toEqual(user.id); }); + + it('should require a valid shareId', async () => { + const res = await server.post('/api/documents.info', { + body: { shareId: 123 }, + }); + expect(res.status).toEqual(400); + }); }); describe('#documents.list', async () => { From 8c62b6e07a10a1e85a52009444235b40a146812b Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 26 May 2018 12:22:14 -0700 Subject: [PATCH 22/23] Improve error screen for not found share link --- README.md | 2 +- app/scenes/Document/Document.js | 3 ++- app/scenes/Error404/Error404.js | 30 ++++++++++++------------------ app/scenes/Search/Search.js | 2 +- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2fe74c6ba..3cdacf430 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Outline is still built and maintained by a small team – we'd love your help to However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster! -If you're looking for ways to get started, here's a list of ways to help us improve Outline: +If you’re looking for ways to get started, here's a list of ways to help us improve Outline: * Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label * Performance improvements, both on server and frontend diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index c2a0df021..534f04c7b 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -31,6 +31,7 @@ import LoadingIndicator from 'components/LoadingIndicator'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import Search from 'scenes/Search'; +import Error404 from 'scenes/Error404'; const AUTOSAVE_INTERVAL = 3000; const DISCARD_CHANGES = ` @@ -237,7 +238,7 @@ class DocumentScene extends React.Component { const isShare = match.params.shareId; if (this.notFound) { - return ; + return isShare ? : ; } return ( diff --git a/app/scenes/Error404/Error404.js b/app/scenes/Error404/Error404.js index 041767bd2..dfab52b8c 100644 --- a/app/scenes/Error404/Error404.js +++ b/app/scenes/Error404/Error404.js @@ -1,25 +1,19 @@ // @flow import * as React from 'react'; -import { Link } from 'react-router-dom'; - import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; -class Error404 extends React.Component<*> { - render() { - return ( - - -

Not Found

- -

We're unable to find the page you're accessing.

- -

- Maybe you want to try search instead? -

-
- ); - } -} +const Error404 = () => { + return ( + + +

Not Found

+

We were unable to find the page you’re looking for.

+

+ Go to homepage. +

+
+ ); +}; export default Error404; diff --git a/app/scenes/Search/Search.js b/app/scenes/Search/Search.js index 3e823117d..e4a031ba4 100644 --- a/app/scenes/Search/Search.js +++ b/app/scenes/Search/Search.js @@ -121,7 +121,7 @@ class Search extends React.Component { @action loadMoreResults = async () => { - // Don't paginate if there aren't more results or we're in the middle of fetching + // Don't paginate if there aren't more results or we’re in the middle of fetching if (!this.allowLoadMore || this.isFetching) return; // Fetch more results From 67e3431fe30fc428ca56e71388bc3583f196bdb3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 26 May 2018 13:29:42 -0700 Subject: [PATCH 23/23] More extensive specs around documents.info endpoint now that it doesn't require auth --- server/api/documents.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 86aa35335..770fc4555 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -74,6 +74,23 @@ describe('#documents.info', async () => { expect(body.data.updatedBy.id).toEqual(user.id); }); + it('should require authorization without token', async () => { + const { document } = await seed(); + const res = await server.post('/api/documents.info', { + body: { id: document.id }, + }); + expect(res.status).toEqual(403); + }); + + it('should require authorization with incorrect token', async () => { + const { document } = await seed(); + const user = await buildUser(); + const res = await server.post('/api/documents.info', { + body: { token: user.getJwtToken(), id: document.id }, + }); + expect(res.status).toEqual(403); + }); + it('should require a valid shareId', async () => { const res = await server.post('/api/documents.info', { body: { shareId: 123 },