Merge pull request #11 from jorilallo/jori-user-accounts
Jori user accounts
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
66
server/api/__snapshots__/auth.test.js.snap
Normal file
66
server/api/__snapshots__/auth.test.js.snap
Normal 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
|
||||
}
|
||||
`;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
158
server/api/auth.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
46
server/migrations/20160911230444-user-optional-slack-id.js
Normal file
46
server/migrations/20160911230444-user-optional-slack-id.js
Normal 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,
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
44
server/migrations/20160911232911-user-unique-fields.js
Normal file
44
server/migrations/20160911232911-user-unique-fields.js
Normal 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,
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
12
server/migrations/20160911234928-user-password.js
Normal file
12
server/migrations/20160911234928-user-password.js
Normal 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');
|
||||
},
|
||||
};
|
||||
@@ -126,6 +126,6 @@ Document.searchForUser = async (user, query, options = {}) => {
|
||||
);
|
||||
|
||||
return documents;
|
||||
}
|
||||
};
|
||||
|
||||
export default Document;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
22
server/models/User.test.js
Normal file
22
server/models/User.test.js
Normal 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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ function runMigrations() {
|
||||
path: './server/migrations',
|
||||
},
|
||||
});
|
||||
umzug.up()
|
||||
return umzug.up()
|
||||
.then(() => {
|
||||
sequelize.close();
|
||||
return sequelize.close();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user