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'])
|
@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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
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 {
|
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",
|
||||||
|
|||||||
@@ -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
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(() => 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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
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;
|
return documents;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Document;
|
export default Document;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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"
|
"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) => {
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ function runMigrations() {
|
|||||||
path: './server/migrations',
|
path: './server/migrations',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
umzug.up()
|
return umzug.up()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sequelize.close();
|
return sequelize.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user