;
- declare function compare(
- data: string,
- encrypted: string,
- callback: (err: Error, same: boolean) => void
- ): void;
- declare function getRounds(encrypted: string): number;
-}
diff --git a/package.json b/package.json
index 4280a5ca6..89f27d6c0 100644
--- a/package.json
+++ b/package.json
@@ -82,7 +82,6 @@
"babel-preset-react": "6.11.1",
"babel-preset-react-hmre": "1.1.1",
"babel-regenerator-runtime": "6.5.0",
- "bcrypt": "1.0.3",
"boundless-arrow-key-navigation": "^1.0.4",
"boundless-popover": "^1.0.4",
"bugsnag": "^1.7.0",
@@ -112,6 +111,7 @@
"js-search": "^1.4.2",
"json-loader": "0.5.4",
"jsonwebtoken": "7.0.1",
+ "jszip": "3.1.5",
"koa": "^2.2.0",
"koa-bodyparser": "4.2.0",
"koa-compress": "2.0.0",
@@ -134,7 +134,7 @@
"nodemailer": "^4.4.0",
"normalize.css": "^7.0.0",
"normalizr": "2.0.1",
- "outline-icons": "^1.2.0",
+ "outline-icons": "^1.3.2",
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",
@@ -157,7 +157,7 @@
"react-waypoint": "^7.3.1",
"redis": "^2.6.2",
"redis-lock": "^0.1.0",
- "rich-markdown-editor": "1.2.0",
+ "rich-markdown-editor": "2.0.4",
"safestart": "1.1.0",
"sequelize": "4.28.6",
"sequelize-cli": "^2.7.0",
@@ -170,6 +170,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/public/favicon-16.png b/public/favicon-16.png
index 9fa7ae5be..622a8bf3d 100644
Binary files a/public/favicon-16.png and b/public/favicon-16.png differ
diff --git a/public/favicon-32.png b/public/favicon-32.png
index 763503409..59fc4a62b 100644
Binary files a/public/favicon-32.png and b/public/favicon-32.png differ
diff --git a/public/fonts/AtlasGrotesk-Black-Web.woff b/public/fonts/AtlasGrotesk-Black-Web.woff
deleted file mode 100644
index ccdb3e0fa..000000000
Binary files a/public/fonts/AtlasGrotesk-Black-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-BlackItalic-Web.woff b/public/fonts/AtlasGrotesk-BlackItalic-Web.woff
deleted file mode 100644
index 5187478c9..000000000
Binary files a/public/fonts/AtlasGrotesk-BlackItalic-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-Bold-Web.woff b/public/fonts/AtlasGrotesk-Bold-Web.woff
deleted file mode 100644
index d730123df..000000000
Binary files a/public/fonts/AtlasGrotesk-Bold-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-BoldItalic-Web.woff b/public/fonts/AtlasGrotesk-BoldItalic-Web.woff
deleted file mode 100644
index 772297800..000000000
Binary files a/public/fonts/AtlasGrotesk-BoldItalic-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-Light-Web.woff b/public/fonts/AtlasGrotesk-Light-Web.woff
deleted file mode 100644
index 462eef4d6..000000000
Binary files a/public/fonts/AtlasGrotesk-Light-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-LightItalic-Web.woff b/public/fonts/AtlasGrotesk-LightItalic-Web.woff
deleted file mode 100644
index 56cbf058c..000000000
Binary files a/public/fonts/AtlasGrotesk-LightItalic-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-Medium-Web.woff b/public/fonts/AtlasGrotesk-Medium-Web.woff
deleted file mode 100644
index 9ffcb647c..000000000
Binary files a/public/fonts/AtlasGrotesk-Medium-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-MediumItalic-Web.woff b/public/fonts/AtlasGrotesk-MediumItalic-Web.woff
deleted file mode 100644
index 0f5772674..000000000
Binary files a/public/fonts/AtlasGrotesk-MediumItalic-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-Regular-Web.woff b/public/fonts/AtlasGrotesk-Regular-Web.woff
deleted file mode 100644
index 3559d95ef..000000000
Binary files a/public/fonts/AtlasGrotesk-Regular-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-RegularItalic-Web (1).woff b/public/fonts/AtlasGrotesk-RegularItalic-Web (1).woff
deleted file mode 100644
index 29747cd97..000000000
Binary files a/public/fonts/AtlasGrotesk-RegularItalic-Web (1).woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-RegularItalic-Web.woff b/public/fonts/AtlasGrotesk-RegularItalic-Web.woff
deleted file mode 100644
index 29747cd97..000000000
Binary files a/public/fonts/AtlasGrotesk-RegularItalic-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-Thin-Web.woff b/public/fonts/AtlasGrotesk-Thin-Web.woff
deleted file mode 100644
index 13fb2f298..000000000
Binary files a/public/fonts/AtlasGrotesk-Thin-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasGrotesk-ThinItalic-Web.woff b/public/fonts/AtlasGrotesk-ThinItalic-Web.woff
deleted file mode 100644
index 48e881794..000000000
Binary files a/public/fonts/AtlasGrotesk-ThinItalic-Web.woff and /dev/null differ
diff --git a/public/fonts/AtlasTypewriterMedium.woff b/public/fonts/AtlasTypewriterMedium.woff
deleted file mode 100644
index d1ec16578..000000000
Binary files a/public/fonts/AtlasTypewriterMedium.woff and /dev/null differ
diff --git a/public/fonts/AtlasTypewriterRegular.woff b/public/fonts/AtlasTypewriterRegular.woff
deleted file mode 100644
index 0b22f747a..000000000
Binary files a/public/fonts/AtlasTypewriterRegular.woff and /dev/null differ
diff --git a/public/icon-192.png b/public/icon-192.png
new file mode 100644
index 000000000..35c78f28e
Binary files /dev/null and b/public/icon-192.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
new file mode 100644
index 000000000..3b31990dd
Binary files /dev/null and b/public/icon-512.png differ
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 000000000..63f35b601
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,20 @@
+{
+ "short_name": "Outline",
+ "name": "Outline",
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "/icon-512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": "?source=pwa",
+ "background_color": "#FFFFFF",
+ "display": "standalone",
+ "theme_color": "#FFFFFF"
+}
\ No newline at end of file
diff --git a/public/screenshot.png b/public/screenshot.png
index 11fb8c365..6a0ef50dc 100644
Binary files a/public/screenshot.png and b/public/screenshot.png differ
diff --git a/public/screenshot@2x.png b/public/screenshot@2x.png
index fe214876e..da2cb8156 100644
Binary files a/public/screenshot@2x.png and b/public/screenshot@2x.png differ
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/__snapshots__/shares.test.js.snap b/server/api/__snapshots__/shares.test.js.snap
index 77cc5d4df..439b96bb9 100644
--- a/server/api/__snapshots__/shares.test.js.snap
+++ b/server/api/__snapshots__/shares.test.js.snap
@@ -17,3 +17,12 @@ Object {
"status": 401,
}
`;
+
+exports[`#shares.revoke should require authentication 1`] = `
+Object {
+ "error": "authentication_required",
+ "message": "Authentication required",
+ "ok": false,
+ "status": 401,
+}
+`;
diff --git a/server/api/__snapshots__/team.test.js.snap b/server/api/__snapshots__/team.test.js.snap
index eac4cccf3..a92a594e2 100644
--- a/server/api/__snapshots__/team.test.js.snap
+++ b/server/api/__snapshots__/team.test.js.snap
@@ -5,12 +5,14 @@ Object {
"data": Array [
Object {
"avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
"name": "Admin User",
"username": "admin",
},
Object {
"avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"name": "User 1",
"username": "user1",
@@ -31,15 +33,7 @@ Object {
"data": Array [
Object {
"avatarUrl": "http://example.com/avatar.png",
- "email": "admin@example.com",
- "id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
- "isAdmin": true,
- "isSuspended": false,
- "name": "Admin User",
- "username": "admin",
- },
- Object {
- "avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
@@ -47,6 +41,16 @@ Object {
"name": "User 1",
"username": "user1",
},
+ Object {
+ "avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
+ "email": "admin@example.com",
+ "id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
+ "isAdmin": true,
+ "isSuspended": false,
+ "name": "Admin User",
+ "username": "admin",
+ },
],
"ok": true,
"pagination": Object {
diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap
index 40a7610de..ea9827a22 100644
--- a/server/api/__snapshots__/user.test.js.snap
+++ b/server/api/__snapshots__/user.test.js.snap
@@ -4,6 +4,7 @@ exports[`#user.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
@@ -25,10 +26,20 @@ Object {
}
`;
+exports[`#user.delete should require authentication 1`] = `
+Object {
+ "error": "authentication_required",
+ "message": "Authentication required",
+ "ok": false,
+ "status": 401,
+}
+`;
+
exports[`#user.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
@@ -59,32 +70,11 @@ Object {
}
`;
-exports[`#user.info should require authentication 1`] = `
-Object {
- "error": "authentication_required",
- "message": "Authentication required",
- "ok": false,
- "status": 401,
-}
-`;
-
-exports[`#user.info should return known user 1`] = `
-Object {
- "data": Object {
- "avatarUrl": "http://example.com/avatar.png",
- "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
- "name": "User 1",
- "username": "user1",
- },
- "ok": true,
- "status": 200,
-}
-`;
-
exports[`#user.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": true,
@@ -119,6 +109,7 @@ exports[`#user.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
@@ -153,6 +144,7 @@ exports[`#user.update should update user profile information 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
+ "createdAt": "2018-01-01T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
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/api/documents.js b/server/api/documents.js
index 0813df7e7..f06e655f8 100644
--- a/server/api/documents.js
+++ b/server/api/documents.js
@@ -165,7 +165,12 @@ router.post('documents.info', auth({ required: false }), async ctx => {
let document;
if (shareId) {
- const share = await Share.findById(shareId, {
+ const share = await Share.find({
+ where: {
+ // $FlowFixMe
+ revokedAt: { [Op.eq]: null },
+ id: shareId,
+ },
include: [
{
model: Document,
diff --git a/server/api/documents.test.js b/server/api/documents.test.js
index 770fc4555..7abd0547f 100644
--- a/server/api/documents.test.js
+++ b/server/api/documents.test.js
@@ -36,7 +36,7 @@ describe('#documents.info', async () => {
expect(body.data.id).toEqual(document.id);
});
- it('should return redacted documents from shareId without token', async () => {
+ it('should return redacted document from shareId without token', async () => {
const { document } = await seed();
const share = await buildShare({
documentId: document.id,
@@ -55,6 +55,20 @@ describe('#documents.info', async () => {
expect(body.data.updatedBy).toEqual(undefined);
});
+ it('should not return document from revoked shareId', async () => {
+ const { document, user } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: document.teamId,
+ });
+ await share.revoke(user.id);
+
+ const res = await server.post('/api/documents.info', {
+ body: { shareId: share.id },
+ });
+ expect(res.status).toEqual(400);
+ });
+
it('should return documents from shareId with token', async () => {
const { user, document, collection } = await seed();
const share = await buildShare({
diff --git a/server/api/shares.js b/server/api/shares.js
index 677b0222f..15caf93ba 100644
--- a/server/api/shares.js
+++ b/server/api/shares.js
@@ -1,11 +1,13 @@
// @flow
import Router from 'koa-router';
+import Sequelize from 'sequelize';
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 Op = Sequelize.Op;
const { authorize } = policy;
const router = new Router();
@@ -14,7 +16,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 };
+ const where = {
+ teamId: user.teamId,
+ userId: user.id,
+ // $FlowFixMe
+ revokedAt: { [Op.eq]: null },
+ };
if (user.isAdmin) delete where.userId;
@@ -68,15 +75,15 @@ router.post('shares.create', auth(), async ctx => {
};
});
-router.post('shares.delete', auth(), async ctx => {
+router.post('shares.revoke', 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(user, 'delete', share);
+ authorize(user, 'revoke', share);
- await share.destroy();
+ await share.revoke(user.id);
ctx.body = {
success: true,
diff --git a/server/api/shares.test.js b/server/api/shares.test.js
index 730007779..c768f42ae 100644
--- a/server/api/shares.test.js
+++ b/server/api/shares.test.js
@@ -32,6 +32,24 @@ describe('#shares.list', async () => {
expect(body.data[0].documentTitle).toBe(document.title);
});
+ it('should not return revoked shares', async () => {
+ const { user, document } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: user.teamId,
+ userId: user.id,
+ });
+ await share.revoke(user.id);
+
+ const res = await server.post('/api/shares.list', {
+ body: { token: user.getJwtToken() },
+ });
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.length).toEqual(0);
+ });
+
it('admins should only return shares created by all users', async () => {
const { admin, document } = await seed();
const share = await buildShare({
@@ -106,3 +124,58 @@ describe('#shares.create', async () => {
expect(res.status).toEqual(403);
});
});
+
+describe('#shares.revoke', async () => {
+ it('should allow author to revoke a share', async () => {
+ const { user, document } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: user.teamId,
+ userId: user.id,
+ });
+
+ const res = await server.post('/api/shares.revoke', {
+ body: { token: user.getJwtToken(), id: share.id },
+ });
+ expect(res.status).toEqual(200);
+ });
+
+ it('should allow admin to revoke a share', async () => {
+ const { user, admin, document } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: user.teamId,
+ userId: user.id,
+ });
+
+ const res = await server.post('/api/shares.revoke', {
+ body: { token: admin.getJwtToken(), id: share.id },
+ });
+ expect(res.status).toEqual(200);
+ });
+
+ it('should require authentication', async () => {
+ const { user, document } = await seed();
+ const share = await buildShare({
+ documentId: document.id,
+ teamId: user.teamId,
+ userId: user.id,
+ });
+ const res = await server.post('/api/shares.revoke', {
+ body: { id: share.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(), documentId: document.id },
+ });
+ expect(res.status).toEqual(403);
+ });
+});
diff --git a/server/api/user.js b/server/api/user.js
index 0ab1d9a7c..ad1fcb1a9 100644
--- a/server/api/user.js
+++ b/server/api/user.js
@@ -164,4 +164,22 @@ router.post('user.activate', auth(), async ctx => {
};
});
+router.post('user.delete', auth(), async ctx => {
+ const { confirmation } = ctx.body;
+ ctx.assertPresent(confirmation, 'confirmation is required');
+
+ const user = ctx.state.user;
+ authorize(user, 'delete', user);
+
+ try {
+ await user.destroy();
+ } catch (err) {
+ throw new ValidationError(err.message);
+ }
+
+ ctx.body = {
+ success: true,
+ };
+});
+
export default router;
diff --git a/server/api/user.test.js b/server/api/user.test.js
index f714eac69..f2f7a5a08 100644
--- a/server/api/user.test.js
+++ b/server/api/user.test.js
@@ -3,6 +3,7 @@ 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());
@@ -11,19 +12,60 @@ afterAll(server.close);
describe('#user.info', async () => {
it('should return known user', async () => {
- const { user } = await seed();
+ const user = await buildUser();
const res = await server.post('/api/user.info', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
- expect(body).toMatchSnapshot();
+ expect(body.data.id).toEqual(user.id);
+ expect(body.data.name).toEqual(user.name);
});
it('should require authentication', async () => {
- await seed();
const res = await server.post('/api/user.info');
+ expect(res.status).toEqual(401);
+ });
+});
+
+describe('#user.delete', async () => {
+ it('should not allow deleting without confirmation', async () => {
+ const user = await buildUser();
+ const res = await server.post('/api/user.delete', {
+ body: { token: user.getJwtToken() },
+ });
+ expect(res.status).toEqual(400);
+ });
+
+ it('should allow deleting last admin if only user', async () => {
+ const user = await buildUser({ isAdmin: true });
+ const res = await server.post('/api/user.delete', {
+ body: { token: user.getJwtToken(), confirmation: true },
+ });
+ expect(res.status).toEqual(200);
+ });
+
+ it('should not allow deleting last admin if many users', async () => {
+ const user = await buildUser({ isAdmin: true });
+ await buildUser({ teamId: user.teamId, isAdmin: false });
+
+ const res = await server.post('/api/user.delete', {
+ body: { token: user.getJwtToken(), confirmation: true },
+ });
+ expect(res.status).toEqual(400);
+ });
+
+ it('should allow deleting user account with confirmation', async () => {
+ const user = await buildUser();
+ const res = await server.post('/api/user.delete', {
+ body: { token: user.getJwtToken(), confirmation: true },
+ });
+ expect(res.status).toEqual(200);
+ });
+
+ it('should require authentication', async () => {
+ const res = await server.post('/api/user.delete');
const body = await res.json();
expect(res.status).toEqual(401);
@@ -44,7 +86,6 @@ describe('#user.update', async () => {
});
it('should require authentication', async () => {
- await seed();
const res = await server.post('/api/user.update');
const body = await res.json();
@@ -67,7 +108,7 @@ describe('#user.promote', async () => {
});
it('should require admin', async () => {
- const { user } = await seed();
+ const user = await buildUser();
const res = await server.post('/api/user.promote', {
body: { token: user.getJwtToken(), id: user.id },
});
@@ -96,7 +137,7 @@ describe('#user.demote', async () => {
});
it("shouldn't demote admins if only one available ", async () => {
- const { admin } = await seed();
+ const admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.demote', {
body: {
@@ -111,7 +152,7 @@ describe('#user.demote', async () => {
});
it('should require admin', async () => {
- const { user } = await seed();
+ const user = await buildUser();
const res = await server.post('/api/user.promote', {
body: { token: user.getJwtToken(), id: user.id },
});
@@ -139,8 +180,7 @@ describe('#user.suspend', async () => {
});
it("shouldn't allow suspending the user themselves", async () => {
- const { admin } = await seed();
-
+ const admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.suspend', {
body: {
token: admin.getJwtToken(),
@@ -154,7 +194,7 @@ describe('#user.suspend', async () => {
});
it('should require admin', async () => {
- const { user } = await seed();
+ const user = await buildUser();
const res = await server.post('/api/user.suspend', {
body: { token: user.getJwtToken(), id: user.id },
});
@@ -187,7 +227,7 @@ describe('#user.activate', async () => {
});
it('should require admin', async () => {
- const { user } = await seed();
+ const user = await buildUser();
const res = await server.post('/api/user.activate', {
body: { token: user.getJwtToken(), id: user.id },
});
diff --git a/server/auth/google.js b/server/auth/google.js
index 7553fe52f..c8f28f51e 100644
--- a/server/auth/google.js
+++ b/server/auth/google.js
@@ -12,6 +12,7 @@ const client = new OAuth2Client(
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.URL}/auth/google.callback`
);
+const allowedDomainsEnv = process.env.GOOGLE_ALLOWED_DOMAINS;
// start the oauth process and redirect user to Google
router.get('google', async ctx => {
@@ -43,6 +44,13 @@ router.get('google.callback', async ctx => {
return;
}
+ // allow all domains by default if the env is not set
+ const allowedDomains = allowedDomainsEnv && allowedDomainsEnv.split(',');
+ if (allowedDomains && !allowedDomains.includes(profile.data.hd)) {
+ ctx.redirect('/?notice=hd-not-allowed');
+ return;
+ }
+
const googleId = profile.data.hd;
const teamName = capitalize(profile.data.hd.split('.')[0]);
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.
+
+
+
+ Go to dashboard
+
+
+
+
+
+ );
+};
diff --git a/server/emails/WelcomeEmail.js b/server/emails/WelcomeEmail.js
index 8d0b27b40..e70594268 100644
--- a/server/emails/WelcomeEmail.js
+++ b/server/emails/WelcomeEmail.js
@@ -27,7 +27,6 @@ export const WelcomeEmail = () => {
Welcome to Outline!
-
Outline is a place for your team to build and share knowledge.
To get started, head to your dashboard and try creating a collection
@@ -38,9 +37,7 @@ export const WelcomeEmail = () => {
You can also import existing Markdown document by drag and dropping
them to your collections
-
-
View my dashboard
diff --git a/server/emails/components/EmailLayout.js b/server/emails/components/EmailLayout.js
index 52c6178f1..17e8cf270 100644
--- a/server/emails/components/EmailLayout.js
+++ b/server/emails/components/EmailLayout.js
@@ -1,7 +1,7 @@
// @flow
import * as React from 'react';
import { Table, TBody, TR, TD } from 'oy-vey';
-import { fonts } from '../../../shared/styles/constants';
+import theme from '../../../shared/styles/theme';
type Props = {
children: React.Node,
@@ -19,7 +19,7 @@ export default (props: Props) => (
export const baseStyles = `
#__bodyTable__ {
- font-family: ${fonts.regular};
+ font-family: ${theme.fontFamily};
font-size: 16px;
line-height: 1.5;
}
diff --git a/server/emails/components/Footer.js b/server/emails/components/Footer.js
index 6a211d718..af03cfe87 100644
--- a/server/emails/components/Footer.js
+++ b/server/emails/components/Footer.js
@@ -1,26 +1,26 @@
// @flow
import * as React from 'react';
import { Table, TBody, TR, TD } from 'oy-vey';
-import { color } from '../../../shared/styles/constants';
import { twitterUrl, spectrumUrl } from '../../../shared/utils/routeHelpers';
+import theme from '../../../shared/styles/theme';
export default () => {
const style = {
padding: '20px 0',
- borderTop: `1px solid ${color.smokeDark}`,
- color: color.slate,
+ borderTop: `1px solid ${theme.smokeDark}`,
+ color: theme.slate,
fontSize: '14px',
};
const linkStyle = {
- color: color.slate,
+ color: theme.slate,
fontWeight: 500,
textDecoration: 'none',
marginRight: '10px',
};
const externalLinkStyle = {
- color: color.slate,
+ color: theme.slate,
textDecoration: 'none',
margin: '0 10px',
};
diff --git a/server/emails/index.js b/server/emails/index.js
index 175fc9fb3..92bb4f209 100644
--- a/server/emails/index.js
+++ b/server/emails/index.js
@@ -2,7 +2,7 @@
import Koa from 'koa';
import Router from 'koa-router';
import { NotFoundError } from '../errors';
-import { Mailer } from '../mailer';
+import Mailer from '../mailer';
const emailPreviews = new Koa();
const router = new Router();
diff --git a/server/logistics.js b/server/logistics.js
new file mode 100644
index 000000000..ce79b7d11
--- /dev/null
+++ b/server/logistics.js
@@ -0,0 +1,94 @@
+// @flow
+import Queue from 'bull';
+import debug from 'debug';
+import Mailer from './mailer';
+import { Collection, Team } from './models';
+import { archiveCollection, archiveCollections } from './utils/zip';
+
+const log = debug('logistics');
+const logisticsQueue = new Queue('logistics', process.env.REDIS_URL);
+const mailer = new Mailer();
+const queueOptions = {
+ attempts: 2,
+ backoff: {
+ type: 'exponential',
+ delay: 60 * 1000,
+ },
+};
+
+async function exportAndEmailCollection(collectionId: string, email: string) {
+ log('Archiving collection', collectionId);
+ const collection = await Collection.findById(collectionId);
+ const filePath = await archiveCollection(collection);
+
+ log('Archive path', filePath);
+
+ mailer.export({
+ to: email,
+ attachments: [
+ {
+ filename: `${collection.name} Export.zip`,
+ path: filePath,
+ },
+ ],
+ });
+}
+
+async function exportAndEmailCollections(teamId: string, email: string) {
+ log('Archiving team', teamId);
+ const team = await Team.findById(teamId);
+ const collections = await Collection.findAll({
+ where: { teamId },
+ order: [['name', 'ASC']],
+ });
+ const filePath = await archiveCollections(collections);
+
+ log('Archive path', filePath);
+
+ mailer.export({
+ to: email,
+ attachments: [
+ {
+ filename: `${team.name} Export.zip`,
+ path: filePath,
+ },
+ ],
+ });
+}
+
+logisticsQueue.process(async job => {
+ log('Process', job.data);
+
+ switch (job.data.type) {
+ case 'export-collection':
+ return await exportAndEmailCollection(
+ job.data.collectionId,
+ job.data.email
+ );
+ case 'export-collections':
+ return await exportAndEmailCollections(job.data.teamId, job.data.email);
+ default:
+ }
+});
+
+export const exportCollection = (collectionId: string, email: string) => {
+ logisticsQueue.add(
+ {
+ type: 'export-collection',
+ collectionId,
+ email,
+ },
+ queueOptions
+ );
+};
+
+export const exportCollections = (teamId: string, email: string) => {
+ logisticsQueue.add(
+ {
+ type: 'export-collections',
+ teamId,
+ email,
+ },
+ queueOptions
+ );
+};
diff --git a/server/mailer.js b/server/mailer.js
index 5d5f81079..f2dbd1d6a 100644
--- a/server/mailer.js
+++ b/server/mailer.js
@@ -1,10 +1,17 @@
// @flow
import * as React from 'react';
+import debug from 'debug';
+import bugsnag from 'bugsnag';
import nodemailer from 'nodemailer';
import Oy from 'oy-vey';
import Queue from 'bull';
import { baseStyles } from './emails/components/EmailLayout';
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
+import { ExportEmail, exportEmailText } from './emails/ExportEmail';
+
+const log = debug('emails');
+
+type Emails = 'welcome' | 'export';
type SendMailType = {
to: string,
@@ -14,6 +21,14 @@ type SendMailType = {
text: string,
html: React.Node,
headCSS?: string,
+ attachments?: Object[],
+};
+
+type EmailJob = {
+ data: {
+ type: Emails,
+ opts: SendMailType,
+ },
};
/**
@@ -27,7 +42,7 @@ type SendMailType = {
* HTML: http://localhost:3000/email/:email_type/html
* TEXT: http://localhost:3000/email/:email_type/text
*/
-class Mailer {
+export default class Mailer {
transporter: ?any;
/**
@@ -44,6 +59,7 @@ class Mailer {
});
try {
+ log(`Sending email "${data.title}" to ${data.to}`);
await transporter.sendMail({
from: process.env.SMTP_FROM_EMAIL,
replyTo: process.env.SMTP_REPLY_EMAIL || process.env.SMTP_FROM_EMAIL,
@@ -51,10 +67,11 @@ class Mailer {
subject: data.title,
html: html,
text: data.text,
+ attachments: data.attachments,
});
- } catch (e) {
- Bugsnag.notifyException(e);
- throw e; // Re-throw for queue to re-try
+ } catch (err) {
+ bugsnag.notify(err);
+ throw err; // Re-throw for queue to re-try
}
}
};
@@ -70,17 +87,32 @@ class Mailer {
});
};
+ export = async (opts: { to: string, attachments: Object[] }) => {
+ this.sendMail({
+ to: opts.to,
+ attachments: opts.attachments,
+ title: 'Your requested export',
+ previewText: "Here's your request data export from Outline",
+ html: ,
+ text: exportEmailText,
+ });
+ };
+
constructor() {
if (process.env.SMTP_HOST) {
let smtpConfig = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
- secure: true,
- auth: {
+ secure: process.env.NODE_ENV === 'production',
+ auth: undefined,
+ };
+
+ if (process.env.SMTP_USERNAME) {
+ smtpConfig.auth = {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
- },
- };
+ };
+ }
this.transporter = nodemailer.createTransport(smtpConfig);
}
@@ -88,14 +120,14 @@ class Mailer {
}
const mailer = new Mailer();
-const mailerQueue = new Queue('email', process.env.REDIS_URL);
+export const mailerQueue = new Queue('email', process.env.REDIS_URL);
-mailerQueue.process(async function(job) {
+mailerQueue.process(async (job: EmailJob) => {
// $FlowIssue flow doesn't like dynamic values
await mailer[job.data.type](job.data.opts);
});
-const sendEmail = (type: string, to: string, options?: Object = {}) => {
+export const sendEmail = (type: Emails, to: string, options?: Object = {}) => {
mailerQueue.add(
{
type,
@@ -113,5 +145,3 @@ const sendEmail = (type: string, to: string, options?: Object = {}) => {
}
);
};
-
-export { Mailer, mailerQueue, sendEmail };
diff --git a/server/mailer.test.js b/server/mailer.test.js
index 8d33c199b..fe2023628 100644
--- a/server/mailer.test.js
+++ b/server/mailer.test.js
@@ -1,5 +1,5 @@
/* eslint-disable flowtype/require-valid-file-annotation */
-import { Mailer } from './mailer';
+import Mailer from './mailer';
describe('Mailer', () => {
let fakeMailer;
diff --git a/server/migrations/20180604191743-revoke-share-links.js b/server/migrations/20180604191743-revoke-share-links.js
new file mode 100644
index 000000000..c6f759b78
--- /dev/null
+++ b/server/migrations/20180604191743-revoke-share-links.js
@@ -0,0 +1,19 @@
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn('shares', 'revokedAt', {
+ type: Sequelize.DATE,
+ allowNull: true
+ });
+ await queryInterface.addColumn('shares', 'revokedById', {
+ type: Sequelize.UUID,
+ allowNull: true,
+ references: {
+ model: 'users',
+ },
+ });
+ },
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.removeColumn('shares', 'revokedAt');
+ await queryInterface.removeColumn('shares', 'revokedById');
+ }
+}
\ No newline at end of file
diff --git a/server/migrations/20180707220121-more-soft-delete.js b/server/migrations/20180707220121-more-soft-delete.js
new file mode 100644
index 000000000..fb179eebe
--- /dev/null
+++ b/server/migrations/20180707220121-more-soft-delete.js
@@ -0,0 +1,16 @@
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn('users', 'deletedAt', {
+ type: Sequelize.DATE,
+ allowNull: true
+ });
+ await queryInterface.addColumn('teams', 'deletedAt', {
+ type: Sequelize.DATE,
+ allowNull: true
+ });
+ },
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.removeColumn('users', 'deletedAt');
+ await queryInterface.removeColumn('teams', 'deletedAt');
+ }
+}
\ No newline at end of file
diff --git a/server/migrations/20180707231201-remove-passwords.js b/server/migrations/20180707231201-remove-passwords.js
new file mode 100644
index 000000000..0cd2cc840
--- /dev/null
+++ b/server/migrations/20180707231201-remove-passwords.js
@@ -0,0 +1,11 @@
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.removeColumn('users', 'passwordDigest');
+ },
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn('users', 'passwordDigest', {
+ type: Sequelize.STRING,
+ allowNull: true,
+ });
+ }
+}
\ No newline at end of file
diff --git a/server/migrations/20180708231200-serviceid-null.js b/server/migrations/20180708231200-serviceid-null.js
new file mode 100644
index 000000000..25af19bf6
--- /dev/null
+++ b/server/migrations/20180708231200-serviceid-null.js
@@ -0,0 +1,14 @@
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.changeColumn('users', 'serviceId', {
+ type: Sequelize.STRING,
+ allowNull: true,
+ });
+ },
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.changeColumn('users', 'serviceId', {
+ type: Sequelize.STRING,
+ allowNull: false,
+ });
+ }
+}
\ No newline at end of file
diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js
index d2e115914..47d170960 100644
--- a/server/models/ApiKey.js
+++ b/server/models/ApiKey.js
@@ -31,4 +31,11 @@ const ApiKey = sequelize.define(
}
);
+ApiKey.associate = models => {
+ ApiKey.belongsTo(models.User, {
+ as: 'user',
+ foreignKey: 'userId',
+ });
+};
+
export default ApiKey;
diff --git a/server/models/Document.js b/server/models/Document.js
index 7bcb61d98..bf2c20526 100644
--- a/server/models/Document.js
+++ b/server/models/Document.js
@@ -141,8 +141,8 @@ Document.associate = models => {
{
include: [
{ model: models.Collection, as: 'collection' },
- { model: models.User, as: 'createdBy' },
- { model: models.User, as: 'updatedBy' },
+ { model: models.User, as: 'createdBy', paranoid: false },
+ { model: models.User, as: 'updatedBy', paranoid: false },
],
where: {
publishedAt: {
@@ -156,8 +156,8 @@ Document.associate = models => {
Document.addScope('withUnpublished', {
include: [
{ model: models.Collection, as: 'collection' },
- { model: models.User, as: 'createdBy' },
- { model: models.User, as: 'updatedBy' },
+ { model: models.User, as: 'createdBy', paranoid: false },
+ { model: models.User, as: 'updatedBy', paranoid: false },
],
});
Document.addScope('withViews', userId => ({
diff --git a/server/models/Share.js b/server/models/Share.js
index 3965b8642..6d77b6313 100644
--- a/server/models/Share.js
+++ b/server/models/Share.js
@@ -1,13 +1,25 @@
// @flow
import { DataTypes, sequelize } from '../sequelize';
-const Share = sequelize.define('share', {
- id: {
- type: DataTypes.UUID,
- defaultValue: DataTypes.UUIDV4,
- primaryKey: true,
+const Share = sequelize.define(
+ 'share',
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ revokedAt: DataTypes.DATE,
+ revokedById: DataTypes.UUID,
},
-});
+ {
+ getterMethods: {
+ isRevoked() {
+ return !!this.revokedAt;
+ },
+ },
+ }
+);
Share.associate = models => {
Share.belongsTo(models.User, {
@@ -24,4 +36,10 @@ Share.associate = models => {
});
};
+Share.prototype.revoke = function(userId) {
+ this.revokedAt = new Date();
+ this.revokedById = userId;
+ return this.save();
+};
+
export default Share;
diff --git a/server/models/User.js b/server/models/User.js
index ff3b1a6ef..85e2397b8 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -1,14 +1,12 @@
// @flow
import crypto from 'crypto';
-import bcrypt from 'bcrypt';
import uuid from 'uuid';
import JWT from 'jsonwebtoken';
import subMinutes from 'date-fns/sub_minutes';
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
import { sendEmail } from '../mailer';
-
-const BCRYPT_COST = process.env.NODE_ENV === 'production' ? 12 : 4;
+import { Star, ApiKey } from '.';
const User = sequelize.define(
'user',
@@ -22,21 +20,20 @@ const User = sequelize.define(
username: { type: DataTypes.STRING },
name: DataTypes.STRING,
avatarUrl: { type: DataTypes.STRING, allowNull: true },
- password: DataTypes.VIRTUAL,
- passwordDigest: DataTypes.STRING,
isAdmin: DataTypes.BOOLEAN,
service: { type: DataTypes.STRING, allowNull: true },
serviceId: { type: DataTypes.STRING, allowNull: true, unique: true },
slackData: DataTypes.JSONB,
jwtSecret: encryptedFields.vault('jwtSecret'),
lastActiveAt: DataTypes.DATE,
- lastActiveIp: DataTypes.STRING,
+ lastActiveIp: { type: DataTypes.STRING, allowNull: true },
lastSignedInAt: DataTypes.DATE,
- lastSignedInIp: DataTypes.STRING,
+ lastSignedInIp: { type: DataTypes.STRING, allowNull: true },
suspendedAt: DataTypes.DATE,
suspendedById: DataTypes.UUID,
},
{
+ paranoid: true,
getterMethods: {
isSuspended() {
return !!this.suspendedAt;
@@ -80,24 +77,6 @@ User.prototype.getJwtToken = function() {
return JWT.sign({ id: this.id }, this.jwtSecret);
};
-User.prototype.verifyPassword = function(password) {
- return new Promise((resolve, reject) => {
- if (!this.passwordDigest) {
- resolve(false);
- return;
- }
-
- bcrypt.compare(password, this.passwordDigest, (err, ok) => {
- if (err) {
- reject(err);
- return;
- }
-
- resolve(ok);
- });
- });
-};
-
const uploadAvatar = async model => {
const endpoint = publicS3Endpoint();
@@ -114,26 +93,41 @@ const setRandomJwtSecret = model => {
model.jwtSecret = crypto.randomBytes(64).toString('hex');
};
-const hashPassword = model => {
- if (!model.password) {
- return null;
- }
+const removeIdentifyingInfo = async model => {
+ await ApiKey.destroy({ where: { userId: model.id } });
+ await Star.destroy({ where: { userId: model.id } });
- return new Promise((resolve, reject) => {
- bcrypt.hash(model.password, BCRYPT_COST, (err, digest) => {
- if (err) {
- reject(err);
- return;
- }
+ model.email = '';
+ model.name = 'Unknown';
+ model.avatarUrl = '';
+ model.serviceId = null;
+ model.username = null;
+ model.slackData = null;
+ model.lastActiveIp = null;
+ model.lastSignedInIp = null;
- model.passwordDigest = digest;
- resolve();
- });
- });
+ // this shouldn't be needed once this issue is resolved:
+ // https://github.com/sequelize/sequelize/issues/9318
+ await model.save({ hooks: false });
};
-User.beforeCreate(hashPassword);
-User.beforeUpdate(hashPassword);
+const checkLastAdmin = async model => {
+ const teamId = model.teamId;
+
+ if (model.isAdmin) {
+ const userCount = await User.count({ where: { teamId } });
+ const adminCount = await User.count({ where: { isAdmin: true, teamId } });
+
+ if (userCount > 1 && adminCount <= 1) {
+ throw new Error(
+ 'Cannot delete account as only admin. Please transfer admin permissions to another user and try again.'
+ );
+ }
+ }
+};
+
+User.beforeDestroy(checkLastAdmin);
+User.beforeDestroy(removeIdentifyingInfo);
User.beforeSave(uploadAvatar);
User.beforeCreate(setRandomJwtSecret);
User.afterCreate(user => sendEmail('welcome', user.email));
diff --git a/server/models/User.test.js b/server/models/User.test.js
index 9bdc13b6b..cce0a743b 100644
--- a/server/models/User.test.js
+++ b/server/models/User.test.js
@@ -4,11 +4,7 @@ import { buildUser } from '../test/factories';
beforeEach(flushdb);
-it('should set JWT secret and password digest', async () => {
- const user = await buildUser({ password: 'test123!' });
- expect(user.passwordDigest).toBeTruthy();
+it('should set JWT secret', async () => {
+ const user = await buildUser();
expect(user.getJwtToken()).toBeTruthy();
-
- expect(await user.verifyPassword('test123!')).toBe(true);
- expect(await user.verifyPassword('badPasswd')).toBe(false);
});
diff --git a/server/pages/About.js b/server/pages/About.js
index d635a0229..72cc646f5 100644
--- a/server/pages/About.js
+++ b/server/pages/About.js
@@ -57,8 +57,8 @@ export default function About() {
Outline is currently in public beta. The hosted service will stay
- free during this period. After that we will offer Outline free for
- teams up to 5 people and have reasonable plans for larger teams.
+ free during this period. After that we will offer Outline free to
+ get started and have reasonable plans for larger teams.
@@ -143,10 +143,10 @@ export default function About() {
by supporting us financially.
-
Can I use Google/GitHub/etc to signup for Outline?
- We started with Slack as many teams are already using it and benefit
- from the integrations. We’ll be adding more login methods soon. Please
- let us know which one you would like to see next{' '}
+ Can I use X to signup for Outline?
+ We started with Slack and Google as many teams are already using these
+ services for team identity. We’ll consider adding more login methods
+ soon. Please let us know which one you would like to see next{' '}
How can I export my data if you go away?
- We’re committed on making your data portable. We’ll soon add better
- import and export options so you which will let you take your data and
- view it in HTML form or upload to self-hosted Outline. Until then, you
- can do this through our API .
+ Outline includes the ability to export individual documents,
+ collections or your entire knowledge base to markdown with a single
+ click so you’re never locked in. We also have an extensive{' '}
+ API that can be used for accessing documents
+ programatically.
How can I get in touch with you?
diff --git a/server/pages/Api.js b/server/pages/Api.js
index dee26dd06..0d168b001 100644
--- a/server/pages/Api.js
+++ b/server/pages/Api.js
@@ -327,7 +327,7 @@ export default function Pricing() {
This method returns information for a document with a specific
- ID. Following identifiers are allowed:
+ ID. The following identifiers are allowed:
@@ -340,11 +340,8 @@ export default function Pricing() {
-
+
+
@@ -597,6 +594,34 @@ export default function Pricing() {
+
+
+
+ List all your currently shared document links.
+
+
+
+
+
+
+ Creates a new share link that can be used by anyone to access a
+ document. If you request multiple shares for the same document
+ with the same user the same share will be returned.
+
+
+
+
+
+
+
+
+ Makes the share link inactive so that it can no longer be used to
+ access the document.
+
+
+
+
+