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'])
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 {
</span>
</div>
<Flex className={ styles.headerRight }>
{ user.user && (
<Flex>
<Flex align="center" className={ styles.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 align="center" className={ styles.actions }>
{ this.props.actions }
</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>
</div>

View File

@@ -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 (
<div className={ styles.container }>
<div className={ styles.content }>
<div className={ styles.intro }>
<h1>Atlas</h1>
<p>Simple, fast, markdown.</p>
<p>We're building a modern wiki for engineering teams.</p>
</div>
<div className={ styles.action }>
<SlackAuthLink>
<img
alt="Sign in with Slack"
height="40"
width="172"
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"
/>
</SlackAuthLink>
</div>
</div>
</div>
<Flex auto>
<Layout>
<CenteredContent>
<div className={ styles.intro }>
<h1 className={ styles.title }>Simple, fast, markdown.</h1>
<p className={ styles.copy }>We're building a modern wiki for engineering teams.</p>
</div>
<div className={ styles.action }>
<SlackAuthLink>
<img
alt="Sign in with Slack"
height="40"
width="172"
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"
/>
</SlackAuthLink>
</div>
</CenteredContent>
</Layout>
</Flex>
);
}
}

View File

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

View File

@@ -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",

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 {
"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",

View File

@@ -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,

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

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;
}
};
export default Document;

View File

@@ -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: {

View File

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

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"
}
`;
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) => {
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);
});

View File

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

View File

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

View File

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

View File

@@ -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',