diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index 6bec7cc94..891f2cb74 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -36,13 +36,13 @@ class Layout extends React.Component { @keydown(['/', 't']) search() { - // if (!this.props.search) return; + if (!this.props.user) return; _.defer(() => browserHistory.push('/search')); } @keydown(['d']) dashboard() { - // if (!this.props.search) return; + if (!this.props.user) return; _.defer(() => browserHistory.push('/')); } @@ -75,34 +75,36 @@ class Layout extends React.Component { - { user.user && ( - - - { this.props.actions } - - { this.props.search && ( - -
- Search -
-
- ) } - } - > - Settings - Logout - + + + { this.props.actions } - ) } + { user.user && ( + + { this.props.search && ( + +
+ Search +
+
+ ) } + } + > + Settings + Logout + +
+ ) } +
diff --git a/frontend/scenes/Home/Home.js b/frontend/scenes/Home/Home.js index a39f60226..057b38cf0 100644 --- a/frontend/scenes/Home/Home.js +++ b/frontend/scenes/Home/Home.js @@ -2,6 +2,9 @@ import React from 'react'; import { observer } from 'mobx-react'; import { browserHistory } from 'react-router' +import { Flex } from 'reflexbox'; +import Layout from 'components/Layout'; +import CenteredContent from 'components/CenteredContent'; import SlackAuthLink from 'components/SlackAuthLink'; import styles from './Home.scss'; @@ -20,26 +23,27 @@ export default class Home extends React.Component { render() { return ( -
-
-
-

Atlas

-

Simple, fast, markdown.

-

We're building a modern wiki for engineering teams.

-
-
- - Sign in with Slack - -
-
-
+ + + +
+

Simple, fast, markdown.

+

We're building a modern wiki for engineering teams.

+
+
+ + Sign in with Slack + +
+
+
+
); } } diff --git a/frontend/scenes/Home/Home.scss b/frontend/scenes/Home/Home.scss index 52a2f6d54..0aa7fd743 100644 --- a/frontend/scenes/Home/Home.scss +++ b/frontend/scenes/Home/Home.scss @@ -1,17 +1,9 @@ -.container { - display: flex; - flex: 1; - justify-content: space-around; +.title { + font-size: 36px; + margin-bottom: 24px; } -.content { - margin: 40px; - max-width: 640px; +.copy { + font-size: 20px; + margin-bottom: 36px; } - -.intro { - font-family: "Atlas Typewriter", Monaco, monospace; - font-size: 1.4em; - line-height: 1.6em; - margin-bottom: 40px; -} \ No newline at end of file diff --git a/package.json b/package.json index 07e7b8943..1d5f17322 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "babel-preset-react-hmre": "1.1.1", "babel-preset-stage-0": "6.5.0", "babel-regenerator-runtime": "6.5.0", + "bcrypt": "^0.8.7", "bugsnag": "^1.7.0", "classnames": "2.2.3", "codemirror": "5.16.0", @@ -107,9 +108,9 @@ "lodash.orderby": "4.4.0", "marked": "0.3.6", "marked-sanitized": "^0.1.1", - "mobx": "2.4.3", + "mobx": "2.5.1", "mobx-react": "3.5.5", - "mobx-react-devtools": "4.2.0", + "mobx-react-devtools": "4.2.4", "moment": "2.13.0", "node-dev": "3.1.0", "node-sass": "3.8.0", @@ -127,7 +128,7 @@ "react-dropzone": "3.6.0", "react-helmet": "3.1.0", "react-keydown": "^1.6.1", - "react-router": "2.5.1", + "react-router": "2.8.0", "rebass": "0.2.6", "redis": "^2.6.2", "redis-lock": "^0.1.0", diff --git a/server/api/__snapshots__/auth.test.js.snap b/server/api/__snapshots__/auth.test.js.snap new file mode 100644 index 000000000..716f58eb7 --- /dev/null +++ b/server/api/__snapshots__/auth.test.js.snap @@ -0,0 +1,66 @@ +exports[`#auth.login should login with email 1`] = ` +Object { + "avatarUrl": "http://example.com/avatar.png", + "id": "86fde1d4-0050-428f-9f0b-0bf77f8bdf61", + "name": "User 1", + "username": "user1" +} +`; + +exports[`#auth.login should login with username 1`] = ` +Object { + "avatarUrl": "http://example.com/avatar.png", + "id": "86fde1d4-0050-428f-9f0b-0bf77f8bdf61", + "name": "User 1", + "username": "user1" +} +`; + +exports[`#auth.login should require either username or email 1`] = ` +Object { + "error": "username or email is required", + "ok": false +} +`; + +exports[`#auth.login should require password 1`] = ` +Object { + "error": "password is required", + "ok": false +} +`; + +exports[`#auth.login should validate password 1`] = ` +Object { + "error": "Invalid password", + "ok": false +} +`; + +exports[`#auth.signup should require params 1`] = ` +Object { + "error": "name is required", + "ok": false +} +`; + +exports[`#auth.signup should require unique email 1`] = ` +Object { + "error": "User already exists with this email", + "ok": false +} +`; + +exports[`#auth.signup should require unique username 1`] = ` +Object { + "error": "User already exists with this username", + "ok": false +} +`; + +exports[`#auth.signup should require valid email 1`] = ` +Object { + "error": "email is invalid", + "ok": false +} +`; diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index 5406bc5d2..1fd030521 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -1,11 +1,11 @@ -exports[`test should require authentication 1`] = ` +exports[`#user.info should require authentication 1`] = ` Object { "error": "Authentication required", "ok": false } `; -exports[`test should return known user 1`] = ` +exports[`#user.info should return known user 1`] = ` Object { "data": Object { "avatarUrl": "http://example.com/avatar.png", diff --git a/server/api/auth.js b/server/api/auth.js index 351331ad4..5a7545b0f 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -8,6 +8,60 @@ import { User, Team } from '../models'; const router = new Router(); +router.post('auth.signup', async (ctx) => { + const { username, name, email, password } = ctx.request.body; + + ctx.assertPresent(username, 'name is required'); + ctx.assertPresent(name, 'name is required'); + ctx.assertPresent(email, 'email is required'); + ctx.assertEmail(email, 'email is invalid'); + ctx.assertPresent(password, 'password is required'); + + if (await User.findOne({ where: { email } })) { + throw httpErrors.BadRequest('User already exists with this email'); + } + + if (await User.findOne({ where: { username } })) { + throw httpErrors.BadRequest('User already exists with this username'); + } + + const user = await User.create({ + username, + name, + email, + password, + }); + + ctx.body = { data: { + user: await presentUser(ctx, user), + accessToken: user.getJwtToken(), + } }; +}); + +router.post('auth.login', async (ctx) => { + const { username, email, password } = ctx.request.body; + + 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 } }); + } else { + throw httpErrors.BadRequest('username or email is required'); + } + + if (!await user.verifyPassword(password)) { + throw httpErrors.BadRequest('Invalid password'); + } + + ctx.body = { data: { + user: await presentUser(ctx, user), + accessToken: user.getJwtToken(), + } }; +}); + router.post('auth.slack', async (ctx) => { const { code } = ctx.body; ctx.assertPresent(code, 'code is required'); @@ -34,11 +88,11 @@ router.post('auth.slack', async (ctx) => { if (!allowedSlackIds.includes(data.team.id)) throw httpErrors.BadRequest('Invalid Slack team'); // User - let user = await User.findOne({ where: { slackId: data.user.id }}); + let user = await User.findOne({ where: { slackId: data.user.id } }); // Team let team = await Team.findOne({ where: { slackId: data.team.id } }); - let teamExisted = !!team; + const teamExisted = !!team; if (!team) { team = await Team.create({ name: data.team.name, diff --git a/server/api/auth.test.js b/server/api/auth.test.js new file mode 100644 index 000000000..fec61a649 --- /dev/null +++ b/server/api/auth.test.js @@ -0,0 +1,158 @@ +import TestServer from 'fetch-test-server'; +import app from '..'; +import { flushdb, sequelize, seed } from '../test/support'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(() => server.close()); +afterAll(() => sequelize.close()); + +describe('#auth.signup', async () => { + it('should signup a new user', async () => { + const res = await server.post('/api/auth.signup', { + body: { + username: 'testuser', + name: 'Test User', + email: 'new.user@example.com', + password: 'test123!', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.ok).toBe(true); + expect(body.data.user).toBeTruthy(); + }); + + it('should require params', async () => { + const res = await server.post('/api/auth.signup', { + body: { + username: 'testuser', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + + + it('should require valid email', async () => { + const res = await server.post('/api/auth.signup', { + body: { + username: 'testuser', + name: 'Test User', + email: 'example.com', + password: 'test123!', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + + it('should require unique email', async () => { + await seed(); + const res = await server.post('/api/auth.signup', { + body: { + username: 'testuser', + name: 'Test User', + email: 'user1@example.com', + password: 'test123!', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + + it('should require unique username', async () => { + await seed(); + const res = await server.post('/api/auth.signup', { + body: { + username: 'user1', + name: 'Test User', + email: 'userone@example.com', + password: 'test123!', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#auth.login', () => { + test('should login with email', async () => { + await seed(); + const res = await server.post('/api/auth.login', { + body: { + email: 'user1@example.com', + password: 'test123!', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.ok).toBe(true); + expect(body.data.user).toMatchSnapshot(); + }); + + test('should login with username', async () => { + await seed(); + const res = await server.post('/api/auth.login', { + body: { + username: 'user1', + password: 'test123!', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.ok).toBe(true); + expect(body.data.user).toMatchSnapshot(); + }); + + test('should validate password', async () => { + await seed(); + const res = await server.post('/api/auth.login', { + body: { + email: 'user1@example.com', + password: 'bad_password', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + + test('should require either username or email', async () => { + const res = await server.post('/api/auth.login', { + body: { + password: 'test123!', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + + test('should require password', async () => { + await seed(); + const res = await server.post('/api/auth.login', { + body: { + email: 'user1@example.com', + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); +}); diff --git a/server/api/user.test.js b/server/api/user.test.js index 3768321be..3c7075829 100644 --- a/server/api/user.test.js +++ b/server/api/user.test.js @@ -11,28 +11,30 @@ beforeEach(flushdb); afterAll(() => server.close()); afterAll(() => sequelize.close()); -it('should return known user', async () => { - await seed(); - const user = await User.findOne({ - where: { - email: 'user1@example.com', - }, +describe('#user.info', async () => { + it('should return known user', async () => { + await seed(); + const user = await User.findOne({ + where: { + email: 'user1@example.com', + }, + }); + + 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(); }); - const res = await server.post('/api/user.info', { - body: { token: user.getJwtToken() }, + it('should require authentication', async () => { + await seed(); + const res = await server.post('/api/user.info'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); }); - const body = await res.json(); - - expect(res.status).toEqual(200); - expect(body).toMatchSnapshot(); -}); - -it('should require authentication', async () => { - await seed(); - const res = await server.post('/api/user.info'); - const body = await res.json(); - - expect(res.status).toEqual(401); - expect(body).toMatchSnapshot(); }); diff --git a/server/migrations/20160911230444-user-optional-slack-id.js b/server/migrations/20160911230444-user-optional-slack-id.js new file mode 100644 index 000000000..b1bf1f188 --- /dev/null +++ b/server/migrations/20160911230444-user-optional-slack-id.js @@ -0,0 +1,46 @@ +/* eslint-disable */ +'use strict'; + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.changeColumn( + 'users', + 'slackId', + { + type: Sequelize.STRING, + unique: false, + allowNull: true, + } + ); + queryInterface.changeColumn( + 'teams', + 'slackId', + { + type: Sequelize.STRING, + unique: false, + allowNull: true, + } + ); + }, + + down: function (queryInterface, Sequelize) { + queryInterface.changeColumn( + 'users', + 'slackId', + { + type: Sequelize.STRING, + unique: true, + allowNull: false, + } + ); + queryInterface.changeColumn( + 'teams', + 'slackId', + { + type: Sequelize.STRING, + unique: true, + allowNull: false, + } + ); + }, +}; diff --git a/server/migrations/20160911232911-user-unique-fields.js b/server/migrations/20160911232911-user-unique-fields.js new file mode 100644 index 000000000..5786ff4d0 --- /dev/null +++ b/server/migrations/20160911232911-user-unique-fields.js @@ -0,0 +1,44 @@ +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.changeColumn( + 'users', + 'email', + { + type: Sequelize.STRING, + unique: true, + allowNull: false, + } + ); + queryInterface.changeColumn( + 'users', + 'username', + { + type: Sequelize.STRING, + unique: true, + allowNull: false, + } + ); + }, + + down: function (queryInterface, Sequelize) { + queryInterface.changeColumn( + 'users', + 'email', + { + type: Sequelize.STRING, + unique: false, + allowNull: true, + } + ); + + queryInterface.changeColumn( + 'users', + 'username', + { + type: Sequelize.STRING, + unique: false, + allowNull: true, + } + ); + }, +}; diff --git a/server/migrations/20160911234928-user-password.js b/server/migrations/20160911234928-user-password.js new file mode 100644 index 000000000..f477dcd19 --- /dev/null +++ b/server/migrations/20160911234928-user-password.js @@ -0,0 +1,12 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + queryInterface.addColumn('users', 'passwordDigest', { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + down: (queryInterface, _Sequelize) => { + queryInterface.removeColumn('users', 'passwordDigest'); + }, +}; diff --git a/server/models/Document.js b/server/models/Document.js index ce2f82f21..d5ecb648d 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -126,6 +126,6 @@ Document.searchForUser = async (user, query, options = {}) => { ); return documents; -} +}; export default Document; diff --git a/server/models/Team.js b/server/models/Team.js index 275fd2ef5..eda54bfc6 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -9,7 +9,7 @@ import User from './User'; const Team = sequelize.define('team', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, name: DataTypes.STRING, - slackId: { type: DataTypes.STRING, unique: true }, + slackId: { type: DataTypes.STRING, allowNull: true }, slackData: DataTypes.JSONB, }, { instanceMethods: { diff --git a/server/models/User.js b/server/models/User.js index 896636f86..0d6c2b3b5 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import bcrypt from 'bcrypt'; import { DataTypes, sequelize, @@ -7,14 +8,18 @@ import { import JWT from 'jsonwebtoken'; +const BCRYPT_COST = process.env.NODE_ENV !== 'production' ? 4 : 12; + const User = sequelize.define('user', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, - email: DataTypes.STRING, - username: DataTypes.STRING, + email: { type: DataTypes.STRING, unique: true }, + username: { type: DataTypes.STRING, unique: true }, name: DataTypes.STRING, + password: DataTypes.VIRTUAL, + passwordDigest: DataTypes.STRING, isAdmin: DataTypes.BOOLEAN, slackAccessToken: encryptedFields.vault('slackAccessToken'), - slackId: { type: DataTypes.STRING, unique: true }, + slackId: { type: DataTypes.STRING, allowNull: true }, slackData: DataTypes.JSONB, jwtSecret: encryptedFields.vault('jwtSecret'), }, { @@ -25,6 +30,23 @@ const User = sequelize.define('user', { async getTeam() { return this.team; }, + verifyPassword(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); + }); + }); + }, }, indexes: [ { @@ -36,7 +58,25 @@ const User = sequelize.define('user', { const setRandomJwtSecret = (model) => { model.jwtSecret = crypto.randomBytes(64).toString('hex'); }; +const hashPassword = function hashPassword(model) { + if (!model.password) { + return null; + } + return new Promise((resolve, reject) => { + bcrypt.hash(model.password, BCRYPT_COST, (err, digest) => { + if (err) { + reject(err); + return; + } + + model.passwordDigest = digest; + resolve(); + }); + }); +}; +User.beforeCreate(hashPassword); +User.beforeUpdate(hashPassword); User.beforeCreate(setRandomJwtSecret); export default User; diff --git a/server/models/User.test.js b/server/models/User.test.js new file mode 100644 index 000000000..e130e8a22 --- /dev/null +++ b/server/models/User.test.js @@ -0,0 +1,22 @@ +import { User } from '.'; + +import { flushdb, sequelize } from '../test/support'; + +beforeEach(flushdb); +afterAll(() => sequelize.close()); + +it('should set JWT secret and password digest', async () => { + const user = User.build({ + username: 'user', + name: 'User', + email: 'user1@example.com', + password: 'test123!', + }); + await user.save(); + + expect(user.passwordDigest).toBeTruthy(); + expect(user.getJwtToken()).toBeTruthy(); + + expect(await user.verifyPassword('test123!')).toBe(true); + expect(await user.verifyPassword('badPasswd')).toBe(false); +}); diff --git a/server/presenters/__snapshots__/user.test.js.snap b/server/presenters/__snapshots__/user.test.js.snap index eb254de7b..26af224b1 100644 --- a/server/presenters/__snapshots__/user.test.js.snap +++ b/server/presenters/__snapshots__/user.test.js.snap @@ -6,3 +6,12 @@ Object { "username": "testuser" } `; + +exports[`test presents a user without slack data 1`] = ` +Object { + "avatarUrl": null, + "id": "123", + "name": "Test User", + "username": "testuser" +} +`; diff --git a/server/presenters/user.js b/server/presenters/user.js index 9c22daac5..b8d997d91 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -4,9 +4,9 @@ const presentUser = (ctx, user) => { return new Promise(async (resolve, _reject) => { const data = { id: user.id, - name: user.name, username: user.username, - avatarUrl: user.slackData.image_192, + name: user.name, + avatarUrl: user.slackData ? user.slackData.image_192 : null, }; resolve(data); }); diff --git a/server/presenters/user.test.js b/server/presenters/user.test.js index 879670214..a26c10fdb 100644 --- a/server/presenters/user.test.js +++ b/server/presenters/user.test.js @@ -17,3 +17,17 @@ it('presents a user', async () => { expect(user).toMatchSnapshot(); }); + +it('presents a user without slack data', async () => { + const user = await presentUser( + ctx, + { + id: '123', + name: 'Test User', + username: 'testuser', + slackData: null, + }, + ); + + expect(user).toMatchSnapshot(); +}); diff --git a/server/routes.js b/server/routes.js index d98cf6f7f..3266e832a 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,4 +1,4 @@ -const path = require('path'); +import path from 'path'; import httpErrors from 'http-errors'; import Koa from 'koa'; import Router from 'koa-router'; @@ -21,21 +21,21 @@ router.get('/_health', ctx => (ctx.body = 'OK')); if (process.env.NODE_ENV === 'production') { router.get('/static/*', async (ctx) => { ctx.set({ - 'Cache-Control': `max-age=${356*24*60*60}`, + 'Cache-Control': `max-age=${356 * 24 * 60 * 60}`, }); - const stats = await sendfile(ctx, path.join(__dirname, '../dist/', ctx.path.substring(8))); + await sendfile(ctx, path.join(__dirname, '../dist/', ctx.path.substring(8))); }); router.get('*', async (ctx) => { - const stats = await sendfile(ctx, path.join(__dirname, '../dist/index.html')); + await sendfile(ctx, path.join(__dirname, '../dist/index.html')); if (!ctx.status) ctx.throw(httpErrors.NotFound()); }); koa.use(subdomainRedirect()); } else { router.get('*', async (ctx) => { - const stats = await sendfile(ctx, path.join(__dirname, './static/dev.html')); + await sendfile(ctx, path.join(__dirname, './static/dev.html')); if (!ctx.status) ctx.throw(httpErrors.NotFound()); }); } diff --git a/server/test/helper.js b/server/test/helper.js index f37bc82bb..2823ad678 100644 --- a/server/test/helper.js +++ b/server/test/helper.js @@ -21,9 +21,9 @@ function runMigrations() { path: './server/migrations', }, }); - umzug.up() + return umzug.up() .then(() => { - sequelize.close(); + return sequelize.close(); }); } diff --git a/server/test/support.js b/server/test/support.js index 4a379484c..c1646d62b 100644 --- a/server/test/support.js +++ b/server/test/support.js @@ -16,6 +16,7 @@ const seed = async () => { email: 'user1@example.com', username: 'user1', name: 'User 1', + password: 'test123!', slackId: '123', slackData: { image_192: 'http://example.com/avatar.png',