diff --git a/frontend/components/Document/Document.js b/frontend/components/Document/Document.js index 9902fdfa..1bfd9e7c 100644 --- a/frontend/components/Document/Document.js +++ b/frontend/components/Document/Document.js @@ -3,48 +3,14 @@ import { toJS } from 'mobx'; import { observer } from 'mobx-react'; import PublishingInfo from 'components/PublishingInfo'; +import DocumentHtml from './components/DocumentHtml'; import styles from './Document.scss'; -@observer -class DocumentHtml extends React.Component { - static propTypes = { - html: PropTypes.string.isRequired, - } - - componentDidMount = () => { - this.setExternalLinks(); - } - - componentDidUpdate = () => { - this.setExternalLinks(); - } - - setExternalLinks = () => { - const links = this.refs.content.querySelectorAll('a'); - links.forEach(link => { - if (link.hostname !== window.location.hostname) { - link.target = '_blank'; // eslint-disable-line no-param-reassign - } - }); - } - - render() { - return ( -
- ); - } -} - @observer class Document extends React.Component { static propTypes = { - document: React.PropTypes.object.isRequired, + document: PropTypes.object.isRequired, } render() { @@ -64,6 +30,3 @@ class Document extends React.Component { } export default Document; -export { - DocumentHtml, -}; diff --git a/frontend/components/Document/Document.scss b/frontend/components/Document/Document.scss index a7cbd244..a16145a9 100644 --- a/frontend/components/Document/Document.scss +++ b/frontend/components/Document/Document.scss @@ -4,87 +4,3 @@ width: 100%; padding: 20px 20px 40px 20px; } - -.document { - h1, h2, h3, h4, h5, h6 { - :global { - .anchor { - visibility: hidden; - color: $gray; - } - } - - &:hover { - :global { - .anchor { - visibility: visible; - } - } - } - } - - ul { - padding-left: 1.5em; - - ul { - margin: 0; - } - } - - // pre { - // box-shadow: 1px 1px 1px #f5f5f5; - // } - - blockquote { - font-style: italic; - border-left: 2px solid $lightGray; - padding-left: 0.8em; - } - - table { - width: 100%; - overflow: auto; - display: block; - border-spacing: 0; - border-collapse: collapse; - - thead, tbody { - width: 100%; - } - - thead { - tr { - border-bottom: 2px solid $lightGray; - } - } - - tbody { - tr { - border-bottom: 1px solid $lightGray; - } - } - - tr { - background-color: #fff; - - // &:nth-child(2n) { - // background-color: #f8f8f8; - // } - } - - th, td { - text-align: left; - border: 1px 0 solid $lightGray; - padding: 5px 20px 5px 0; - - &:last-child { - padding-right: 0; - width: 100%; - } - } - - th { - font-weight: bold; - } - } -} diff --git a/frontend/components/Document/components/DocumentHtml/DocumentHtml.js b/frontend/components/Document/components/DocumentHtml/DocumentHtml.js new file mode 100644 index 00000000..7f0a0a1f --- /dev/null +++ b/frontend/components/Document/components/DocumentHtml/DocumentHtml.js @@ -0,0 +1,40 @@ +import React, { PropTypes } from 'react'; +import ReactDOM from 'react-dom'; +import { observer } from 'mobx-react'; + +import styles from './DocumentHtml.scss'; + +@observer +class DocumentHtml extends React.Component { + static propTypes = { + html: PropTypes.string.isRequired, + } + + componentDidMount = () => { + this.setExternalLinks(); + } + + componentDidUpdate = () => { + this.setExternalLinks(); + } + + setExternalLinks = () => { + const links = ReactDOM.findDOMNode(this).querySelectorAll('a'); + links.forEach(link => { + if (link.hostname !== window.location.hostname) { + link.target = '_blank'; // eslint-disable-line no-param-reassign + } + }); + } + + render() { + return ( +
+ ); + } +} + +export default DocumentHtml; diff --git a/frontend/components/Document/components/DocumentHtml/DocumentHtml.scss b/frontend/components/Document/components/DocumentHtml/DocumentHtml.scss new file mode 100644 index 00000000..e13d1ab3 --- /dev/null +++ b/frontend/components/Document/components/DocumentHtml/DocumentHtml.scss @@ -0,0 +1,85 @@ +@import '~styles/constants.scss'; + +.document { + h1, h2, h3, h4, h5, h6 { + :global { + .anchor { + visibility: hidden; + color: $gray; + } + } + + &:hover { + :global { + .anchor { + visibility: visible; + } + } + } + } + + ul { + padding-left: 1.5em; + + ul { + margin: 0; + } + } + + // pre { + // box-shadow: 1px 1px 1px #f5f5f5; + // } + + blockquote { + font-style: italic; + border-left: 2px solid $lightGray; + padding-left: 0.8em; + } + + table { + width: 100%; + overflow: auto; + display: block; + border-spacing: 0; + border-collapse: collapse; + + thead, tbody { + width: 100%; + } + + thead { + tr { + border-bottom: 2px solid $lightGray; + } + } + + tbody { + tr { + border-bottom: 1px solid $lightGray; + } + } + + tr { + background-color: #fff; + + // &:nth-child(2n) { + // background-color: #f8f8f8; + // } + } + + th, td { + text-align: left; + border: 1px 0 solid $lightGray; + padding: 5px 20px 5px 0; + + &:last-child { + padding-right: 0; + width: 100%; + } + } + + th { + font-weight: bold; + } + } +} diff --git a/frontend/components/Document/components/DocumentHtml/DocumentHtml.test.js b/frontend/components/Document/components/DocumentHtml/DocumentHtml.test.js new file mode 100644 index 00000000..5295115d --- /dev/null +++ b/frontend/components/Document/components/DocumentHtml/DocumentHtml.test.js @@ -0,0 +1,20 @@ +/* eslint-disable */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import DocumentHtml from './DocumentHtml'; +import { shallow } from 'enzyme'; + +const testHtml = ` +

test document

+

Hello! internal link

+

Aliens external link

+`; + +test('renders', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('.document').length).toBe(1); +}); diff --git a/frontend/components/Document/components/DocumentHtml/index.js b/frontend/components/Document/components/DocumentHtml/index.js new file mode 100644 index 00000000..8c1e31ed --- /dev/null +++ b/frontend/components/Document/components/DocumentHtml/index.js @@ -0,0 +1,2 @@ +import DocumentHtml from './DocumentHtml'; +export default DocumentHtml; diff --git a/frontend/components/Document/index.js b/frontend/components/Document/index.js index a477d1b8..a31bbbc2 100644 --- a/frontend/components/Document/index.js +++ b/frontend/components/Document/index.js @@ -1,4 +1,5 @@ -import Document, { DocumentHtml } from './Document'; +import Document from './Document'; +import DocumentHtml from './components/DocumentHtml'; export default Document; export { diff --git a/frontend/components/MarkdownEditor/MarkdownEditor.js b/frontend/components/MarkdownEditor/MarkdownEditor.js index f5f4619a..c527cf2c 100644 --- a/frontend/components/MarkdownEditor/MarkdownEditor.js +++ b/frontend/components/MarkdownEditor/MarkdownEditor.js @@ -73,7 +73,7 @@ class MarkdownEditor extends React.Component { formData.append('file', file); } - fetch(data.upload_url, { + fetch(data.uploadUrl, { method: 'post', body: formData, }) diff --git a/frontend/scenes/Flatpage/Flatpage.js b/frontend/scenes/Flatpage/Flatpage.js index 47c16e8e..6d0afbec 100644 --- a/frontend/scenes/Flatpage/Flatpage.js +++ b/frontend/scenes/Flatpage/Flatpage.js @@ -10,7 +10,7 @@ import { convertToMarkdown } from 'utils/markdown'; @observer class Flatpage extends React.Component { static propTypes = { - route: PropTypes.object.isRequired, + route: PropTypes.object, } render() { diff --git a/frontend/utils/ApiClient.js b/frontend/utils/ApiClient.js index ec2ddee9..c4d4c35e 100644 --- a/frontend/utils/ApiClient.js +++ b/frontend/utils/ApiClient.js @@ -64,22 +64,14 @@ class ApiClient { // Handle 401, log out user if (response.status === 401) { - stores.user.logout(); + return stores.user.logout(); } // Handle failed responses - let error; - try { - // Expect API to return JSON - error = JSON.parse(response); - } catch (e) { - // Expect call to fail without JSON response - error = { error: response.statusText }; - } - + const error = {}; error.statusCode = response.status; error.response = response; - return reject(error); + throw error; }) .then((response) => { return response.json(); @@ -91,8 +83,12 @@ class ApiClient { } resolve(json); }) - .catch(() => { - reject({ error: 'Unknown error' }); + .catch(error => { + error.response.json() + .then(json => { + error.data = json; + reject(error); + }); }); }); } diff --git a/package.json b/package.json index 2320c064..5f0e3ad6 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ }, "devDependencies": { "babel-jest": "^15.0.0", + "enzyme": "^2.4.1", "fetch-test-server": "^1.1.0", "fsevents": "1.0.14", "identity-obj-proxy": "^3.0.0", @@ -159,6 +160,7 @@ "koa-webpack-hot-middleware": "1.0.3", "node-dev": "3.1.0", "nodemon": "1.9.1", + "react-addons-test-utils": "^15.3.1", "react-test-renderer": "^15.3.1" } } diff --git a/server/api/__snapshots__/auth.test.js.snap b/server/api/__snapshots__/auth.test.js.snap index 716f58eb..8114c061 100644 --- a/server/api/__snapshots__/auth.test.js.snap +++ b/server/api/__snapshots__/auth.test.js.snap @@ -18,49 +18,63 @@ Object { exports[`#auth.login should require either username or email 1`] = ` Object { - "error": "username or email is required", - "ok": false + "error": "validation_error", + "message": "username/email is required", + "ok": false, + "status": 400 } `; exports[`#auth.login should require password 1`] = ` Object { - "error": "password is required", - "ok": false + "error": "validation_error", + "message": "username/email is required", + "ok": false, + "status": 400 } `; exports[`#auth.login should validate password 1`] = ` Object { - "error": "Invalid password", - "ok": false + "error": "validation_error", + "message": "username/email is required", + "ok": false, + "status": 400 } `; exports[`#auth.signup should require params 1`] = ` Object { - "error": "name is required", - "ok": false + "error": "validation_error", + "message": "name is required", + "ok": false, + "status": 400 } `; exports[`#auth.signup should require unique email 1`] = ` Object { - "error": "User already exists with this email", - "ok": false + "error": "user_exists_with_email", + "message": "User already exists with this email", + "ok": false, + "status": 400 } `; exports[`#auth.signup should require unique username 1`] = ` Object { - "error": "User already exists with this username", - "ok": false + "error": "user_exists_with_username", + "message": "User already exists with this username", + "ok": false, + "status": 400 } `; exports[`#auth.signup should require valid email 1`] = ` Object { - "error": "email is invalid", - "ok": false + "error": "validation_error", + "message": "email is invalid", + "ok": false, + "status": 400 } `; diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index 1fd03052..9f908c99 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -1,7 +1,9 @@ exports[`#user.info should require authentication 1`] = ` Object { - "error": "Authentication required", - "ok": false + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401 } `; @@ -13,6 +15,7 @@ Object { "name": "User 1", "username": "user1" }, - "ok": true + "ok": true, + "status": 200 } `; diff --git a/server/api/auth.js b/server/api/auth.js index 8aa84e72..2a308022 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -1,5 +1,6 @@ import Router from 'koa-router'; -import httpErrors from 'http-errors'; +import Sequelize from 'sequelize'; +import apiError, { httpErrors } from '../errors'; import fetch from 'isomorphic-fetch'; import querystring from 'querystring'; @@ -18,11 +19,11 @@ router.post('auth.signup', async (ctx) => { ctx.assertPresent(password, 'password is required'); if (await User.findOne({ where: { email } })) { - throw httpErrors.BadRequest('User already exists with this email'); + throw apiError(400, 'user_exists_with_email', 'User already exists with this email'); } if (await User.findOne({ where: { username } })) { - throw httpErrors.BadRequest('User already exists with this username'); + throw apiError(400, 'user_exists_with_username', 'User already exists with this username'); } const user = await User.create({ @@ -39,21 +40,31 @@ router.post('auth.signup', async (ctx) => { }); router.post('auth.login', async (ctx) => { - const { username, email, password } = ctx.request.body; + const { username, password } = ctx.request.body; + ctx.assertPresent(username, 'username/email is required'); ctx.assertPresent(password, 'password is required'); let user; if (username) { - user = await User.findOne({ where: { username } }); - } else if (email) { - user = await User.findOne({ where: { email } }); + user = await User.findOne({ where: Sequelize.or( + { email: username }, + { username }, + ) }); } else { - throw httpErrors.BadRequest('username or email is required'); + throw apiError(400, 'invalid_credentials', 'username or email is invalid'); + } + + if (!user) { + throw apiError(400, 'username or email is invalid'); + } + + if (!user.passwordDigest) { + throw apiError(400, 'no_password', 'No password set'); } if (!await user.verifyPassword(password)) { - throw httpErrors.BadRequest('Invalid password'); + throw apiError(400, 'invalid_password', 'Invalid password'); } ctx.body = { data: { @@ -154,8 +165,6 @@ router.post('auth.slackCommands', async (ctx) => { } if (!data.ok) throw httpErrors.BadRequest(data.error); - - ctx.body = { success: true }; }); diff --git a/server/api/auth.test.js b/server/api/auth.test.js index fec61a64..68c43f00 100644 --- a/server/api/auth.test.js +++ b/server/api/auth.test.js @@ -91,7 +91,7 @@ describe('#auth.login', () => { await seed(); const res = await server.post('/api/auth.login', { body: { - email: 'user1@example.com', + username: 'user1@example.com', password: 'test123!', }, }); diff --git a/server/api/index.js b/server/api/index.js index d56501bc..d37b9c13 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -2,6 +2,7 @@ import bodyParser from 'koa-bodyparser'; import Koa from 'koa'; import Router from 'koa-router'; import Sequelize from 'sequelize'; +import _ from 'lodash'; import auth from './auth'; import user from './user'; @@ -41,7 +42,9 @@ api.use(async (ctx, next) => { ctx.body = { ok: false, - error: message, + error: _.snakeCase(err.id || err.message), + status: err.status, + message, }; } }); diff --git a/server/api/middlewares/apiWrapper.js b/server/api/middlewares/apiWrapper.js index 70674cdf..bf49bc7a 100644 --- a/server/api/middlewares/apiWrapper.js +++ b/server/api/middlewares/apiWrapper.js @@ -6,6 +6,7 @@ export default function apiWrapper(_options) { ctx.body = { ...ctx.body, + status: ctx.status, ok, }; }; diff --git a/server/api/middlewares/validation.js b/server/api/middlewares/validation.js index 019a8b01..a99d6a59 100644 --- a/server/api/middlewares/validation.js +++ b/server/api/middlewares/validation.js @@ -1,23 +1,23 @@ -import httpErrors from 'http-errors'; +import apiError from '../../errors'; import validator from 'validator'; export default function validation() { return function validationMiddleware(ctx, next) { ctx.assertPresent = function assertPresent(value, message) { if (value === undefined || value === null || value === '') { - throw httpErrors.BadRequest(message); + throw apiError(400, 'validation_error', message); } }; ctx.assertEmail = function assertEmail(value, message) { if (!validator.isEmail(value)) { - throw httpErrors.BadRequest(message); + throw apiError(400, 'validation_error', message); } }; ctx.assertUuid = function assertUuid(value, message) { if (!validator.isUUID(value)) { - throw httpErrors.BadRequest(message); + throw apiError(400, 'validation_error', message); } }; diff --git a/server/api/user.js b/server/api/user.js index 5ebbb945..cad6d4be 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -25,7 +25,7 @@ router.post('user.s3Upload', auth(), async (ctx) => { const policy = makePolicy(); ctx.body = { data: { - max_upload_size: process.env.AWS_S3_UPLOAD_MAX_SIZE, + maxUploadSize: process.env.AWS_S3_UPLOAD_MAX_SIZE, upload_url: process.env.AWS_S3_UPLOAD_BUCKET_URL, form: { AWSAccessKeyId: process.env.AWS_ACCESS_KEY_ID, @@ -37,7 +37,7 @@ router.post('user.s3Upload', auth(), async (ctx) => { policy, }, asset: { - content_type: kind, + contentType: kind, url: `${process.env.AWS_S3_UPLOAD_BUCKET_URL}${s3Key}/${filename}`, name: filename, size, diff --git a/server/errors.js b/server/errors.js new file mode 100644 index 00000000..dae99fee --- /dev/null +++ b/server/errors.js @@ -0,0 +1,10 @@ +import httpErrors from 'http-errors'; + +const apiError = (code, id, message) => { + return httpErrors(code, message, { id }); +}; + +export default apiError; +export { + httpErrors, +};