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,
+};