diff --git a/app/components/Modals/Modals.js b/app/components/Modals/Modals.js index d9a2c23c2..203a5d58f 100644 --- a/app/components/Modals/Modals.js +++ b/app/components/Modals/Modals.js @@ -6,6 +6,7 @@ import UiStore from 'stores/UiStore'; import CollectionNew from 'scenes/CollectionNew'; import CollectionEdit from 'scenes/CollectionEdit'; import CollectionDelete from 'scenes/CollectionDelete'; +import CollectionExport from 'scenes/CollectionExport'; import DocumentDelete from 'scenes/DocumentDelete'; import DocumentShare from 'scenes/DocumentShare'; import KeyboardShortcuts from 'scenes/KeyboardShortcuts'; @@ -45,6 +46,9 @@ class Modals extends React.Component { + + + diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 60af185ad..8a38ba73f 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { observer, inject } from 'mobx-react'; import { + DocumentIcon, ProfileIcon, SettingsIcon, CodeIcon, @@ -74,6 +75,11 @@ class SettingsSidebar extends React.Component { Integrations )} + {user.isAdmin && ( + }> + Export Data + + )} diff --git a/app/index.js b/app/index.js index ac4689e4a..8d64d53cf 100644 --- a/app/index.js +++ b/app/index.js @@ -28,6 +28,7 @@ import People from 'scenes/Settings/People'; import Slack from 'scenes/Settings/Slack'; import Shares from 'scenes/Settings/Shares'; import Tokens from 'scenes/Settings/Tokens'; +import Export from 'scenes/Settings/Export'; import Error404 from 'scenes/Error404'; import ErrorBoundary from 'components/ErrorBoundary'; @@ -96,6 +97,11 @@ if (element) { path="/settings/integrations/slack" component={Slack} /> + { this.props.ui.setActiveModal('collection-delete', { collection }); }; + onExport = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + const { collection } = this.props; + this.props.ui.setActiveModal('collection-export', { collection }); + }; + render() { const { collection, label, onOpen, onClose } = this.props; @@ -87,6 +93,9 @@ class CollectionMenu extends React.Component {
Edit… + + Export… + )} Delete… diff --git a/app/models/Collection.js b/app/models/Collection.js index e9616fca3..91881f2e2 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -133,6 +133,11 @@ class Collection extends BaseModel { return false; }; + @action + export = async () => { + await client.post('/collections.export', { id: this.id }); + }; + @action updateData(data: Object = {}) { this.data = data; diff --git a/app/scenes/CollectionExport/CollectionExport.js b/app/scenes/CollectionExport/CollectionExport.js new file mode 100644 index 000000000..8bfa656ed --- /dev/null +++ b/app/scenes/CollectionExport/CollectionExport.js @@ -0,0 +1,55 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; +import Button from 'components/Button'; +import Flex from 'shared/components/Flex'; +import HelpText from 'components/HelpText'; +import Collection from 'models/Collection'; +import AuthStore from 'stores/AuthStore'; +import UiStore from 'stores/UiStore'; + +type Props = { + collection: Collection, + auth: AuthStore, + ui: UiStore, + onSubmit: () => void, +}; + +@observer +class CollectionExport extends React.Component { + @observable isLoading: boolean = false; + + handleSubmit = async (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + + this.isLoading = true; + await this.props.collection.export(); + this.isLoading = false; + + this.props.ui.showToast('Export in progress…', 'success'); + this.props.onSubmit(); + }; + + render() { + const { collection, auth } = this.props; + if (!auth.user) return; + + return ( + +
+ + Exporting the collection {collection.name} may take + a few minutes. We’ll put together a zip file of your documents in + Markdown format and email it to {auth.user.email}. + + +
+
+ ); + } +} + +export default inject('ui', 'auth')(CollectionExport); diff --git a/app/scenes/CollectionExport/index.js b/app/scenes/CollectionExport/index.js new file mode 100644 index 000000000..025b78c96 --- /dev/null +++ b/app/scenes/CollectionExport/index.js @@ -0,0 +1,3 @@ +// @flow +import CollectionExport from './CollectionExport'; +export default CollectionExport; diff --git a/app/scenes/Settings/Export.js b/app/scenes/Settings/Export.js new file mode 100644 index 000000000..402a811f1 --- /dev/null +++ b/app/scenes/Settings/Export.js @@ -0,0 +1,71 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { observer, inject } from 'mobx-react'; +import AuthStore from 'stores/AuthStore'; +import CollectionsStore from 'stores/CollectionsStore'; +import UiStore from 'stores/UiStore'; + +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; +import HelpText from 'components/HelpText'; +import Button from 'components/Button'; + +type Props = { + auth: AuthStore, + collections: CollectionsStore, + ui: UiStore, +}; + +@observer +class Export extends React.Component { + @observable isLoading: boolean = false; + @observable isExporting: boolean = false; + + handleSubmit = async (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.isLoading = true; + + const success = await this.props.collections.export(); + + if (success) { + this.isExporting = true; + this.props.ui.showToast('Export in progress…', 'success'); + } + this.isLoading = false; + }; + + render() { + const { auth } = this.props; + if (!auth.user) return; + + return ( + + +

Export Data

+ + Exporting your teams documents may take a little time depending on the + size of your knowledgebase. Consider exporting a single document or + collection instead. + + + Still want to export everything in your wiki? We’ll put together a zip + file of your collections and documents in Markdown format and email it + to {auth.user.email}. + + +
+ ); + } +} + +export default inject('auth', 'ui', 'collections')(Export); diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index d23f610e1..59db057c0 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -137,6 +137,16 @@ class CollectionsStore extends BaseStore { } }; + @action + export = async () => { + try { + await client.post('/collections.exportAll'); + return true; + } catch (err) { + throw err; + } + }; + @action add = (collection: Collection): void => { this.data.set(collection.id, collection); diff --git a/package.json b/package.json index 9b18ed413..8a9c29540 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "imports-loader": "0.6.5", "invariant": "^2.2.2", "isomorphic-fetch": "2.2.1", + "jszip": "3.1.5", "js-cookie": "^2.1.4", "js-search": "^1.4.2", "json-loader": "0.5.4", @@ -134,7 +135,7 @@ "nodemailer": "^4.4.0", "normalize.css": "^7.0.0", "normalizr": "2.0.1", - "outline-icons": "^1.3.1", + "outline-icons": "^1.3.2", "oy-vey": "^0.10.0", "pg": "^6.1.5", "pg-hstore": "2.3.2", @@ -170,6 +171,7 @@ "styled-components-breakpoint": "^1.0.1", "styled-components-grid": "^1.0.0-preview.15", "styled-normalize": "^2.2.1", + "tmp": "0.0.33", "uglifyjs-webpack-plugin": "1.2.5", "url-loader": "^0.6.2", "uuid": "2.0.2", diff --git a/server/__snapshots__/mailer.test.js.snap b/server/__snapshots__/mailer.test.js.snap index 78fb41518..fbdca8c9b 100644 --- a/server/__snapshots__/mailer.test.js.snap +++ b/server/__snapshots__/mailer.test.js.snap @@ -2,6 +2,7 @@ exports[`Mailer #welcome 1`] = ` Object { + "attachments": undefined, "from": "hello@example.com", "html": " diff --git a/server/api/__snapshots__/collections.test.js.snap b/server/api/__snapshots__/collections.test.js.snap index 5002b47e7..92266fad2 100644 --- a/server/api/__snapshots__/collections.test.js.snap +++ b/server/api/__snapshots__/collections.test.js.snap @@ -18,6 +18,24 @@ Object { } `; +exports[`#collections.export should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#collections.exportAll should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + exports[`#collections.info should require authentication 1`] = ` Object { "error": "authentication_required", diff --git a/server/api/collections.js b/server/api/collections.js index 85ed0b0b4..a15942674 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -4,8 +4,9 @@ import Router from 'koa-router'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentCollection } from '../presenters'; -import { Collection } from '../models'; +import { Collection, Team } from '../models'; import { ValidationError } from '../errors'; +import { exportCollection, exportCollections } from '../logistics'; import policy from '../policies'; const { authorize } = policy; @@ -46,6 +47,35 @@ router.post('collections.info', auth(), async ctx => { }; }); +router.post('collections.export', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const collection = await Collection.findById(id); + authorize(user, 'export', collection); + + // async operation to create zip archive and email user + exportCollection(id, user.email); + + ctx.body = { + success: true, + }; +}); + +router.post('collections.exportAll', auth(), async ctx => { + const user = ctx.state.user; + const team = await Team.findById(user.teamId); + authorize(user, 'export', team); + + // async operation to create zip archive and email user + exportCollections(user.teamId, user.email); + + ctx.body = { + success: true, + }; +}); + router.post('collections.update', auth(), async ctx => { const { id, name, color } = ctx.body; ctx.assertPresent(name, 'name is required'); diff --git a/server/api/collections.test.js b/server/api/collections.test.js index fcc7248ac..70d25f2bd 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -31,6 +31,52 @@ describe('#collections.list', async () => { }); }); +describe('#collections.export', async () => { + it('should require authentication', async () => { + const res = await server.post('/api/collections.export'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should return success', async () => { + const { user, collection } = await seed(); + const res = await server.post('/api/collections.export', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + + expect(res.status).toEqual(200); + }); +}); + +describe('#collections.exportAll', async () => { + it('should require authentication', async () => { + const res = await server.post('/api/collections.exportAll'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const user = await buildUser(); + const res = await server.post('/api/collections.exportAll', { + body: { token: user.getJwtToken() }, + }); + expect(res.status).toEqual(403); + }); + + it('should return success', async () => { + const { admin } = await seed(); + const res = await server.post('/api/collections.exportAll', { + body: { token: admin.getJwtToken() }, + }); + + expect(res.status).toEqual(200); + }); +}); + describe('#collections.info', async () => { it('should return collection', async () => { const { user, collection } = await seed(); diff --git a/server/emails/ExportEmail.js b/server/emails/ExportEmail.js new file mode 100644 index 000000000..f3d50c352 --- /dev/null +++ b/server/emails/ExportEmail.js @@ -0,0 +1,36 @@ +// @flow +import * as React from 'react'; +import EmailTemplate from './components/EmailLayout'; +import Body from './components/Body'; +import Button from './components/Button'; +import Heading from './components/Heading'; +import Header from './components/Header'; +import Footer from './components/Footer'; +import EmptySpace from './components/EmptySpace'; + +export const exportEmailText = ` +Your Data Export + +Your requested data export is attached as a zip file to this email. +`; + +export const ExportEmail = () => { + return ( + +
+ + + Your Data Export +

+ Your requested data export is attached as a zip file to this email. +

+ +

+ +

+ + +