Merge pull request #11 from jorilallo/jori-user-accounts

Jori user accounts
This commit is contained in:
Jori Lallo
2016-09-12 22:28:39 -07:00
committed by GitHub
22 changed files with 572 additions and 105 deletions

View File

@@ -36,13 +36,13 @@ class Layout extends React.Component {
@keydown(['/', 't']) @keydown(['/', 't'])
search() { search() {
// if (!this.props.search) return; if (!this.props.user) return;
_.defer(() => browserHistory.push('/search')); _.defer(() => browserHistory.push('/search'));
} }
@keydown(['d']) @keydown(['d'])
dashboard() { dashboard() {
// if (!this.props.search) return; if (!this.props.user) return;
_.defer(() => browserHistory.push('/')); _.defer(() => browserHistory.push('/'));
} }
@@ -75,34 +75,36 @@ class Layout extends React.Component {
</span> </span>
</div> </div>
<Flex className={ styles.headerRight }> <Flex className={ styles.headerRight }>
{ user.user && ( <Flex>
<Flex> <Flex align="center" className={ styles.actions }>
<Flex align="center" className={ styles.actions }> { this.props.actions }
{ this.props.actions }
</Flex>
{ this.props.search && (
<Flex>
<div
onClick={ this.search }
className={ styles.search }
title="Search (/)"
>
<img src={ require('assets/icons/search.svg') } alt="Search" />
</div>
</Flex>
) }
<DropdownMenu
label={ <Avatar
circle
size={ 24 }
src={ user.user.avatarUrl }
/> }
>
<MenuItem to="/settings">Settings</MenuItem>
<MenuItem onClick={ user.logout }>Logout</MenuItem>
</DropdownMenu>
</Flex> </Flex>
) } { user.user && (
<Flex>
{ this.props.search && (
<Flex>
<div
onClick={ this.search }
className={ styles.search }
title="Search (/)"
>
<img src={ require('assets/icons/search.svg') } alt="Search" />
</div>
</Flex>
) }
<DropdownMenu
label={ <Avatar
circle
size={ 24 }
src={ user.user.avatarUrl }
/> }
>
<MenuItem to="/settings">Settings</MenuItem>
<MenuItem onClick={ user.logout }>Logout</MenuItem>
</DropdownMenu>
</Flex>
) }
</Flex>
</Flex> </Flex>
</div> </div>

View File

@@ -2,6 +2,9 @@ import React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { browserHistory } from 'react-router' 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 SlackAuthLink from 'components/SlackAuthLink';
import styles from './Home.scss'; import styles from './Home.scss';
@@ -20,26 +23,27 @@ export default class Home extends React.Component {
render() { render() {
return ( return (
<div className={ styles.container }> <Flex auto>
<div className={ styles.content }> <Layout>
<div className={ styles.intro }> <CenteredContent>
<h1>Atlas</h1> <div className={ styles.intro }>
<p>Simple, fast, markdown.</p> <h1 className={ styles.title }>Simple, fast, markdown.</h1>
<p>We're building a modern wiki for engineering teams.</p> <p className={ styles.copy }>We're building a modern wiki for engineering teams.</p>
</div> </div>
<div className={ styles.action }> <div className={ styles.action }>
<SlackAuthLink> <SlackAuthLink>
<img <img
alt="Sign in with Slack" alt="Sign in with Slack"
height="40" height="40"
width="172" width="172"
src="https://platform.slack-edge.com/img/sign_in_with_slack.png" src="https://platform.slack-edge.com/img/sign_in_with_slack.png"
srcSet="https://platform.slack-edge.com/img/sign_in_with_slack.png 1x, https://platform.slack-edge.com/img/sign_in_with_slack@2x.png 2x" srcSet="https://platform.slack-edge.com/img/sign_in_with_slack.png 1x, https://platform.slack-edge.com/img/sign_in_with_slack@2x.png 2x"
/> />
</SlackAuthLink> </SlackAuthLink>
</div> </div>
</div> </CenteredContent>
</div> </Layout>
</Flex>
); );
} }
} }

View File

@@ -1,17 +1,9 @@
.container { .title {
display: flex; font-size: 36px;
flex: 1; margin-bottom: 24px;
justify-content: space-around;
} }
.content { .copy {
margin: 40px; font-size: 20px;
max-width: 640px; margin-bottom: 36px;
}
.intro {
font-family: "Atlas Typewriter", Monaco, monospace;
font-size: 1.4em;
line-height: 1.6em;
margin-bottom: 40px;
} }

View File

@@ -62,6 +62,7 @@
"babel-preset-react-hmre": "1.1.1", "babel-preset-react-hmre": "1.1.1",
"babel-preset-stage-0": "6.5.0", "babel-preset-stage-0": "6.5.0",
"babel-regenerator-runtime": "6.5.0", "babel-regenerator-runtime": "6.5.0",
"bcrypt": "^0.8.7",
"bugsnag": "^1.7.0", "bugsnag": "^1.7.0",
"classnames": "2.2.3", "classnames": "2.2.3",
"codemirror": "5.16.0", "codemirror": "5.16.0",
@@ -107,9 +108,9 @@
"lodash.orderby": "4.4.0", "lodash.orderby": "4.4.0",
"marked": "0.3.6", "marked": "0.3.6",
"marked-sanitized": "^0.1.1", "marked-sanitized": "^0.1.1",
"mobx": "2.4.3", "mobx": "2.5.1",
"mobx-react": "3.5.5", "mobx-react": "3.5.5",
"mobx-react-devtools": "4.2.0", "mobx-react-devtools": "4.2.4",
"moment": "2.13.0", "moment": "2.13.0",
"node-dev": "3.1.0", "node-dev": "3.1.0",
"node-sass": "3.8.0", "node-sass": "3.8.0",
@@ -127,7 +128,7 @@
"react-dropzone": "3.6.0", "react-dropzone": "3.6.0",
"react-helmet": "3.1.0", "react-helmet": "3.1.0",
"react-keydown": "^1.6.1", "react-keydown": "^1.6.1",
"react-router": "2.5.1", "react-router": "2.8.0",
"rebass": "0.2.6", "rebass": "0.2.6",
"redis": "^2.6.2", "redis": "^2.6.2",
"redis-lock": "^0.1.0", "redis-lock": "^0.1.0",

View File

@@ -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
}
`;

View File

@@ -1,11 +1,11 @@
exports[`test should require authentication 1`] = ` exports[`#user.info should require authentication 1`] = `
Object { Object {
"error": "Authentication required", "error": "Authentication required",
"ok": false "ok": false
} }
`; `;
exports[`test should return known user 1`] = ` exports[`#user.info should return known user 1`] = `
Object { Object {
"data": Object { "data": Object {
"avatarUrl": "http://example.com/avatar.png", "avatarUrl": "http://example.com/avatar.png",

View File

@@ -8,6 +8,60 @@ import { User, Team } from '../models';
const router = new Router(); 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) => { router.post('auth.slack', async (ctx) => {
const { code } = ctx.body; const { code } = ctx.body;
ctx.assertPresent(code, 'code is required'); 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'); if (!allowedSlackIds.includes(data.team.id)) throw httpErrors.BadRequest('Invalid Slack team');
// User // User
let user = await User.findOne({ where: { slackId: data.user.id }}); let user = await User.findOne({ where: { slackId: data.user.id } });
// Team // Team
let team = await Team.findOne({ where: { slackId: data.team.id } }); let team = await Team.findOne({ where: { slackId: data.team.id } });
let teamExisted = !!team; const teamExisted = !!team;
if (!team) { if (!team) {
team = await Team.create({ team = await Team.create({
name: data.team.name, name: data.team.name,

158
server/api/auth.test.js Normal file
View File

@@ -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();
});
});

View File

@@ -11,28 +11,30 @@ beforeEach(flushdb);
afterAll(() => server.close()); afterAll(() => server.close());
afterAll(() => sequelize.close()); afterAll(() => sequelize.close());
it('should return known user', async () => { describe('#user.info', async () => {
await seed(); it('should return known user', async () => {
const user = await User.findOne({ await seed();
where: { const user = await User.findOne({
email: 'user1@example.com', 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', { it('should require authentication', async () => {
body: { token: user.getJwtToken() }, 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();
}); });

View File

@@ -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,
}
);
},
};

View File

@@ -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,
}
);
},
};

View File

@@ -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');
},
};

View File

@@ -126,6 +126,6 @@ Document.searchForUser = async (user, query, options = {}) => {
); );
return documents; return documents;
} };
export default Document; export default Document;

View File

@@ -9,7 +9,7 @@ import User from './User';
const Team = sequelize.define('team', { const Team = sequelize.define('team', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
name: DataTypes.STRING, name: DataTypes.STRING,
slackId: { type: DataTypes.STRING, unique: true }, slackId: { type: DataTypes.STRING, allowNull: true },
slackData: DataTypes.JSONB, slackData: DataTypes.JSONB,
}, { }, {
instanceMethods: { instanceMethods: {

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import bcrypt from 'bcrypt';
import { import {
DataTypes, DataTypes,
sequelize, sequelize,
@@ -7,14 +8,18 @@ import {
import JWT from 'jsonwebtoken'; import JWT from 'jsonwebtoken';
const BCRYPT_COST = process.env.NODE_ENV !== 'production' ? 4 : 12;
const User = sequelize.define('user', { const User = sequelize.define('user', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
email: DataTypes.STRING, email: { type: DataTypes.STRING, unique: true },
username: DataTypes.STRING, username: { type: DataTypes.STRING, unique: true },
name: DataTypes.STRING, name: DataTypes.STRING,
password: DataTypes.VIRTUAL,
passwordDigest: DataTypes.STRING,
isAdmin: DataTypes.BOOLEAN, isAdmin: DataTypes.BOOLEAN,
slackAccessToken: encryptedFields.vault('slackAccessToken'), slackAccessToken: encryptedFields.vault('slackAccessToken'),
slackId: { type: DataTypes.STRING, unique: true }, slackId: { type: DataTypes.STRING, allowNull: true },
slackData: DataTypes.JSONB, slackData: DataTypes.JSONB,
jwtSecret: encryptedFields.vault('jwtSecret'), jwtSecret: encryptedFields.vault('jwtSecret'),
}, { }, {
@@ -25,6 +30,23 @@ const User = sequelize.define('user', {
async getTeam() { async getTeam() {
return this.team; 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: [ indexes: [
{ {
@@ -36,7 +58,25 @@ const User = sequelize.define('user', {
const setRandomJwtSecret = (model) => { const setRandomJwtSecret = (model) => {
model.jwtSecret = crypto.randomBytes(64).toString('hex'); 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); User.beforeCreate(setRandomJwtSecret);
export default User; export default User;

View File

@@ -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);
});

View File

@@ -6,3 +6,12 @@ Object {
"username": "testuser" "username": "testuser"
} }
`; `;
exports[`test presents a user without slack data 1`] = `
Object {
"avatarUrl": null,
"id": "123",
"name": "Test User",
"username": "testuser"
}
`;

View File

@@ -4,9 +4,9 @@ const presentUser = (ctx, user) => {
return new Promise(async (resolve, _reject) => { return new Promise(async (resolve, _reject) => {
const data = { const data = {
id: user.id, id: user.id,
name: user.name,
username: user.username, username: user.username,
avatarUrl: user.slackData.image_192, name: user.name,
avatarUrl: user.slackData ? user.slackData.image_192 : null,
}; };
resolve(data); resolve(data);
}); });

View File

@@ -17,3 +17,17 @@ it('presents a user', async () => {
expect(user).toMatchSnapshot(); 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();
});

View File

@@ -1,4 +1,4 @@
const path = require('path'); import path from 'path';
import httpErrors from 'http-errors'; import httpErrors from 'http-errors';
import Koa from 'koa'; import Koa from 'koa';
import Router from 'koa-router'; import Router from 'koa-router';
@@ -21,21 +21,21 @@ router.get('/_health', ctx => (ctx.body = 'OK'));
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
router.get('/static/*', async (ctx) => { router.get('/static/*', async (ctx) => {
ctx.set({ 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) => { 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()); if (!ctx.status) ctx.throw(httpErrors.NotFound());
}); });
koa.use(subdomainRedirect()); koa.use(subdomainRedirect());
} else { } else {
router.get('*', async (ctx) => { 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()); if (!ctx.status) ctx.throw(httpErrors.NotFound());
}); });
} }

View File

@@ -21,9 +21,9 @@ function runMigrations() {
path: './server/migrations', path: './server/migrations',
}, },
}); });
umzug.up() return umzug.up()
.then(() => { .then(() => {
sequelize.close(); return sequelize.close();
}); });
} }

View File

@@ -16,6 +16,7 @@ const seed = async () => {
email: 'user1@example.com', email: 'user1@example.com',
username: 'user1', username: 'user1',
name: 'User 1', name: 'User 1',
password: 'test123!',
slackId: '123', slackId: '123',
slackData: { slackData: {
image_192: 'http://example.com/avatar.png', image_192: 'http://example.com/avatar.png',