API & Client: registration controls

This commit is contained in:
SamR1 2018-01-01 11:10:39 +01:00
parent 62d87d05f5
commit cac4f368bf
16 changed files with 346 additions and 73 deletions

View File

@ -14,3 +14,6 @@ serve-react:
serve: serve:
$(MAKE) P="serve-react serve-python" make-p $(MAKE) P="serve-react serve-python" make-p
test-python:
$(FLASK) test

View File

@ -14,7 +14,8 @@ class TestAuthBlueprint(BaseTestCase):
data=json.dumps(dict( data=json.dumps(dict(
username='justatest', username='justatest',
email='test@test.com', email='test@test.com',
password='123456' password='12345678',
password_conf='12345678'
)), )),
content_type='application/json' content_type='application/json'
) )
@ -26,14 +27,15 @@ class TestAuthBlueprint(BaseTestCase):
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
def test_user_registration_user_already_exists(self): def test_user_registration_user_already_exists(self):
add_user('test', 'test@test.com', 'test') add_user('test', 'test@test.com', '12345678')
with self.client: with self.client:
response = self.client.post( response = self.client.post(
'/api/auth/register', '/api/auth/register',
data=json.dumps(dict( data=json.dumps(dict(
username='test', username='test',
email='test@test.com', email='test@test.com',
password='test' password='12345678',
password_conf='12345678'
)), )),
content_type='application/json' content_type='application/json'
) )
@ -44,6 +46,101 @@ class TestAuthBlueprint(BaseTestCase):
self.assertTrue(response.content_type == 'application/json') self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_user_registration_invalid_short_username(self):
with self.client:
response = self.client.post(
'/api/auth/register',
data=json.dumps(dict(
username='t',
email='test@test.com',
password='12345678',
password_conf='12345678'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'error')
self.assertTrue(
data['message'] == "Errors: Username: 3 to 12 characters required.\n")
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 400)
def test_user_registration_invalid_long_username(self):
with self.client:
response = self.client.post(
'/api/auth/register',
data=json.dumps(dict(
username='testestestestestest',
email='test@test.com',
password='12345678',
password_conf='12345678'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'error')
self.assertTrue(
data['message'] == "Errors: Username: 3 to 12 characters required.\n")
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 400)
def test_user_registration_invalid_email(self):
with self.client:
response = self.client.post(
'/api/auth/register',
data=json.dumps(dict(
username='test',
email='test@test',
password='12345678',
password_conf='12345678'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'error')
self.assertTrue(
data['message'] == "Errors: Valid email must be provided.\n")
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 400)
def test_user_registration_invalid_short_password(self):
with self.client:
response = self.client.post(
'/api/auth/register',
data=json.dumps(dict(
username='test',
email='test@test.com',
password='1234567',
password_conf='1234567'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'error')
self.assertTrue(
data['message'] == "Errors: Password: 8 characters required.\n")
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 400)
def test_user_registration_mismatched_password(self):
with self.client:
response = self.client.post(
'/api/auth/register',
data=json.dumps(dict(
username='test',
email='test@test.com',
password='12345678',
password_conf='87654321'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'error')
self.assertTrue(
data['message'] == "Errors: Password and password confirmation don\'t match.\n")
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 400)
def test_user_registration_invalid_json(self): def test_user_registration_invalid_json(self):
with self.client: with self.client:
response = self.client.post( response = self.client.post(
@ -60,7 +157,10 @@ class TestAuthBlueprint(BaseTestCase):
with self.client: with self.client:
response = self.client.post( response = self.client.post(
'/api/auth/register', '/api/auth/register',
data=json.dumps(dict(email='test@test.com', password='test')), data=json.dumps(dict(
email='test@test.com',
password='12345678',
password_conf='12345678')),
content_type='application/json', content_type='application/json',
) )
data = json.loads(response.data.decode()) data = json.loads(response.data.decode())
@ -73,7 +173,9 @@ class TestAuthBlueprint(BaseTestCase):
response = self.client.post( response = self.client.post(
'/api/auth/register', '/api/auth/register',
data=json.dumps(dict( data=json.dumps(dict(
username='test', password='test')), username='test',
password='12345678',
password_conf='12345678')),
content_type='application/json', content_type='application/json',
) )
data = json.loads(response.data.decode()) data = json.loads(response.data.decode())
@ -86,7 +188,24 @@ class TestAuthBlueprint(BaseTestCase):
response = self.client.post( response = self.client.post(
'/api/auth/register', '/api/auth/register',
data=json.dumps(dict( data=json.dumps(dict(
username='test', email='test@test.com')), username='test',
email='test@test.com',
password_conf='12345678')),
content_type='application/json',
)
data = json.loads(response.data.decode())
self.assertEqual(response.status_code, 400)
self.assertIn('Invalid payload.', data['message'])
self.assertIn('error', data['status'])
def test_user_registration_invalid_json_keys_no_password_conf(self):
with self.client:
response = self.client.post(
'/api/auth/register',
data=json.dumps(dict(
username='test',
email='test@test.com',
password='12345678')),
content_type='application/json', content_type='application/json',
) )
data = json.loads(response.data.decode()) data = json.loads(response.data.decode())
@ -96,12 +215,12 @@ class TestAuthBlueprint(BaseTestCase):
def test_registered_user_login(self): def test_registered_user_login(self):
with self.client: with self.client:
add_user('test', 'test@test.com', 'test') add_user('test', 'test@test.com', '12345678')
response = self.client.post( response = self.client.post(
'/api/auth/login', '/api/auth/login',
data=json.dumps(dict( data=json.dumps(dict(
email='test@test.com', email='test@test.com',
password='test' password='12345678'
)), )),
content_type='application/json' content_type='application/json'
) )
@ -118,25 +237,42 @@ class TestAuthBlueprint(BaseTestCase):
'/api/auth/login', '/api/auth/login',
data=json.dumps(dict( data=json.dumps(dict(
email='test@test.com', email='test@test.com',
password='test' password='12345678'
)), )),
content_type='application/json' content_type='application/json'
) )
data = json.loads(response.data.decode()) data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'error') self.assertTrue(data['status'] == 'error')
self.assertTrue(data['message'] == 'User does not exist.') self.assertTrue(data['message'] == 'Invalid credentials.')
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 404)
def test_registered_user_login_invalid_password(self):
add_user('test', 'test@test.com', '12345678')
with self.client:
response = self.client.post(
'/api/auth/login',
data=json.dumps(dict(
email='test@test.com',
password='123456789'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'error')
self.assertTrue(data['message'] == 'Invalid credentials.')
self.assertTrue(response.content_type == 'application/json') self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_valid_logout(self): def test_valid_logout(self):
add_user('test', 'test@test.com', 'test') add_user('test', 'test@test.com', '12345678')
with self.client: with self.client:
# user login # user login
resp_login = self.client.post( resp_login = self.client.post(
'/api/auth/login', '/api/auth/login',
data=json.dumps(dict( data=json.dumps(dict(
email='test@test.com', email='test@test.com',
password='test' password='12345678'
)), )),
content_type='application/json' content_type='application/json'
) )
@ -155,13 +291,13 @@ class TestAuthBlueprint(BaseTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_invalid_logout_expired_token(self): def test_invalid_logout_expired_token(self):
add_user('test', 'test@test.com', 'test') add_user('test', 'test@test.com', '12345678')
with self.client: with self.client:
resp_login = self.client.post( resp_login = self.client.post(
'/api/auth/login', '/api/auth/login',
data=json.dumps(dict( data=json.dumps(dict(
email='test@test.com', email='test@test.com',
password='test' password='12345678'
)), )),
content_type='application/json' content_type='application/json'
) )

View File

@ -4,7 +4,7 @@ from sqlalchemy import exc, or_
from mpwo_api import appLog, bcrypt, db from mpwo_api import appLog, bcrypt, db
from .models import User from .models import User
from .utils import authenticate from .utils import authenticate, register_controls
auth_blueprint = Blueprint('auth', __name__) auth_blueprint = Blueprint('auth', __name__)
@ -15,7 +15,8 @@ def register_user():
post_data = request.get_json() post_data = request.get_json()
if not post_data or post_data.get('username') is None \ if not post_data or post_data.get('username') is None \
or post_data.get('email') is None \ or post_data.get('email') is None \
or post_data.get('password') is None: or post_data.get('password') is None \
or post_data.get('password_conf') is None:
response_object = { response_object = {
'status': 'error', 'status': 'error',
'message': 'Invalid payload.' 'message': 'Invalid payload.'
@ -24,6 +25,16 @@ def register_user():
username = post_data.get('username') username = post_data.get('username')
email = post_data.get('email') email = post_data.get('email')
password = post_data.get('password') password = post_data.get('password')
password_conf = post_data.get('password_conf')
ret = register_controls(username, email, password, password_conf)
if ret != '':
response_object = {
'status': 'error',
'message': 'Errors: ' + ret
}
return jsonify(response_object), 400
try: try:
# check for existing user # check for existing user
user = User.query.filter( user = User.query.filter(
@ -90,7 +101,7 @@ def login_user():
else: else:
response_object = { response_object = {
'status': 'error', 'status': 'error',
'message': 'User does not exist.' 'message': 'Invalid credentials.'
} }
return jsonify(response_object), 404 return jsonify(response_object), 404
# handler errors # handler errors

View File

@ -1,4 +1,5 @@
from functools import wraps from functools import wraps
import re
from flask import request, jsonify from flask import request, jsonify
@ -33,3 +34,21 @@ def authenticate(f):
def is_admin(user_id): def is_admin(user_id):
user = User.query.filter_by(id=user_id).first() user = User.query.filter_by(id=user_id).first()
return user.admin return user.admin
def is_valid_email(email):
mail_pattern = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)"
return re.match(mail_pattern, email) is not None
def register_controls(username, email, password, password_conf):
ret = ''
if not 2 < len(username) < 13:
ret += 'Username: 3 to 12 characters required.\n'
if not is_valid_email(email):
ret += 'Valid email must be provided.\n'
if password != password_conf:
ret += 'Password and password confirmation don\'t match.\n'
if len(password) < 8:
ret += 'Password: 8 characters required.\n'
return ret

View File

@ -26,7 +26,7 @@ def seed_db():
def test(): def test():
"""Runs the tests without code coverage.""" """Runs the tests without code coverage."""
tests = unittest.TestLoader().discover( tests = unittest.TestLoader().discover(
'mpwo_api/tests', pattern='test*.py') 'mpwo_api/mpwo_api/tests', pattern='test*.py')
result = unittest.TextTestRunner(verbosity=2).run(tests) result = unittest.TextTestRunner(verbosity=2).run(tests)
if result.wasSuccessful(): if result.wasSuccessful():
return 0 return 0

View File

@ -7,6 +7,7 @@
"react": "^16.2.0", "react": "^16.2.0",
"react-dom": "^16.2.0", "react-dom": "^16.2.0",
"react-helmet": "^5.2.0", "react-helmet": "^5.2.0",
"react-key-index": "^0.1.1",
"react-redux": "^5.0.6", "react-redux": "^5.0.6",
"react-router-dom": "^4.2.2", "react-router-dom": "^4.2.2",
"react-router-redux": "^5.0.0-alpha.9", "react-router-redux": "^5.0.0-alpha.9",

View File

@ -1,9 +1,14 @@
import keyIndex from 'react-key-index'
import mpwoApi from '../mpwoApi' import mpwoApi from '../mpwoApi'
function AuthError(message) { function AuthError(message) {
return { type: 'AUTH_ERROR', message } return { type: 'AUTH_ERROR', message }
} }
function AuthErrors(messages) {
return { type: 'AUTH_ERRORS', messages }
}
function ProfileSuccess(message) { function ProfileSuccess(message) {
return { type: 'PROFILE_SUCCESS', message } return { type: 'PROFILE_SUCCESS', message }
} }
@ -27,6 +32,11 @@ const updateFormDataPassword = value => ({
password: value, password: value,
}) })
const updateFormDataPasswordConf = value => ({
type: 'UPDATE_FORMDATA_PASSWORD_CONF',
passwordConf: value,
})
export function getProfile(dispatch) { export function getProfile(dispatch) {
return mpwoApi return mpwoApi
.getProfile() .getProfile()
@ -46,7 +56,11 @@ export function getProfile(dispatch) {
export function register(formData) { export function register(formData) {
return function(dispatch) { return function(dispatch) {
return mpwoApi return mpwoApi
.register(formData.username, formData.email, formData.password) .register(
formData.username,
formData.email,
formData.password,
formData.passwordConf)
.then(ret => { .then(ret => {
if (ret.status === 'success') { if (ret.status === 'success') {
window.localStorage.setItem('authToken', ret.auth_token) window.localStorage.setItem('authToken', ret.auth_token)
@ -92,6 +106,20 @@ export function logout() {
return { type: 'LOGOUT' } return { type: 'LOGOUT' }
} }
function RegisterFormControl (formData) {
const errMsg = []
if (formData.username.length < 3 || formData.username.length > 12) {
errMsg.push('Username: 3 to 12 characters required.')
}
if (formData.password !== formData.passwordConf) {
errMsg.push('Password and password confirmation don\'t match.')
}
if (formData.password.length < 8) {
errMsg.push('Password: 8 characters required.')
}
return errMsg
}
export function handleUserFormSubmit(event, formType) { export function handleUserFormSubmit(event, formType) {
event.preventDefault() event.preventDefault()
return (dispatch, getState) => { return (dispatch, getState) => {
@ -101,7 +129,12 @@ export function handleUserFormSubmit(event, formType) {
if (formType === 'Login') { if (formType === 'Login') {
dispatch(login(formData.formData)) dispatch(login(formData.formData))
} else { // formType === 'Register' } else { // formType === 'Register'
dispatch(register(formData.formData)) const ret = RegisterFormControl(formData.formData)
if (ret.length === 0) {
dispatch(register(formData.formData))
} else {
dispatch(AuthErrors(keyIndex(ret, 1)))
}
} }
} }
} }
@ -112,7 +145,9 @@ export const handleFormChange = event => dispatch => {
return dispatch(updateFormDataEmail(event.target.value)) return dispatch(updateFormDataEmail(event.target.value))
case 'username': case 'username':
return dispatch(updateFormDataUsername(event.target.value)) return dispatch(updateFormDataUsername(event.target.value))
default: // case 'password': case 'password':
return dispatch(updateFormDataPassword(event.target.value)) return dispatch(updateFormDataPassword(event.target.value))
default: // case 'password-conf':
return dispatch(updateFormDataPasswordConf(event.target.value))
} }
} }

View File

@ -1,5 +1,5 @@
.App { .App {
/*text-align: center;*/ text-align: center;
} }
.App-logo { .App-logo {
@ -27,6 +27,10 @@
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.card {
text-align: left;
}
.page-title { .page-title {
font-size: 2em; font-size: 2em;
margin: 1em; margin: 1em;

View File

@ -1,10 +1,13 @@
import React from 'react' import React from 'react'
import { Helmet } from 'react-helmet'
export default function Form (props) { export default function Form (props) {
return ( return (
<div> <div>
<h1>{props.formType}</h1> <Helmet>
<title>mpwo - {props.formType}</title>
</Helmet>
<h1 className="page-title">{props.formType}</h1>
<div className="container"> <div className="container">
<div className="row"> <div className="row">
<div className="col-md-3" /> <div className="col-md-3" />
@ -13,7 +16,7 @@ export default function Form (props) {
<form onSubmit={event => <form onSubmit={event =>
props.handleUserFormSubmit(event, props.formType)} props.handleUserFormSubmit(event, props.formType)}
> >
{props.formType === 'Register' && {props.formType === 'Register' &&
<div className="form-group"> <div className="form-group">
<input <input
name="username" name="username"
@ -24,16 +27,16 @@ export default function Form (props) {
onChange={props.onHandleFormChange} onChange={props.onHandleFormChange}
/> />
</div> </div>
} }
<div className="form-group"> <div className="form-group">
<input <input
name="email" name="email"
className="form-control input-lg" className="form-control input-lg"
type="email" type="email"
placeholder="Enter an email address" placeholder="Enter an email address"
required required
onChange={props.onHandleFormChange} onChange={props.onHandleFormChange}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<input <input
@ -45,6 +48,18 @@ export default function Form (props) {
onChange={props.onHandleFormChange} onChange={props.onHandleFormChange}
/> />
</div> </div>
{props.formType === 'Register' &&
<div className="form-group">
<input
name="password-conf"
className="form-control input-lg"
type="password"
placeholder="Enter password confirmation"
required
onChange={props.onHandleFormChange}
/>
</div>
}
<input <input
type="submit" type="submit"
className="btn btn-primary btn-lg btn-block" className="btn btn-primary btn-lg btn-block"

View File

@ -11,7 +11,7 @@ class Logout extends React.Component {
render() { render() {
return ( return (
<div> <div>
<p> <p className="App-center">
You are now logged out. You are now logged out.
Click <Link to="/login">here</Link> to log back in.</p> Click <Link to="/login">here</Link> to log back in.</p>
</div> </div>

View File

@ -16,6 +16,17 @@ function UserForm(props) {
{ props.message !== '' && ( { props.message !== '' && (
<code>{props.message}</code> <code>{props.message}</code>
)} )}
{ props.messages.length > 0 && (
<code>
<ul>
{props.messages.map(msg => (
<li key={msg.id}>
{msg.value}
</li>
))}
</ul>
</code>
)}
<Form <Form
formType={props.formType} formType={props.formType}
userForm={props.formData} userForm={props.formData}
@ -32,6 +43,7 @@ export default connect(
state => ({ state => ({
formData: state.formData, formData: state.formData,
message: state.message, message: state.message,
messages: state.messages,
}), }),
dispatch => ({ dispatch => ({
onHandleFormChange: event => { onHandleFormChange: event => {

View File

@ -17,7 +17,7 @@ export default class MpwoApi {
.then(response => response.json()) .then(response => response.json())
.catch(error => error) .catch(error => error)
} }
static register(username, email, password) { static register(username, email, password, passwordConf) {
const request = new Request(`${apiUrl}auth/register`, { const request = new Request(`${apiUrl}auth/register`, {
method: 'POST', method: 'POST',
headers: new Headers({ headers: new Headers({
@ -27,6 +27,7 @@ export default class MpwoApi {
username: username, username: username,
email: email, email: email,
password: password, password: password,
password_conf: passwordConf,
}), }),
}) })
return fetch(request) return fetch(request)

View File

@ -2,20 +2,70 @@ import { combineReducers } from 'redux'
import initial from './initial' import initial from './initial'
const formData = (state = initial.formData, action) => {
switch (action.type) {
case 'UPDATE_FORMDATA_EMAIL':
return {
formData: {
...state.formData,
email: action.email
},
}
case 'UPDATE_FORMDATA_USERNAME':
return {
formData: {
...state.formData,
username: action.username
},
}
case 'UPDATE_FORMDATA_PASSWORD':
return {
formData: {
...state.formData,
password: action.password
},
}
case 'UPDATE_FORMDATA_PASSWORD_CONF':
return {
formData: {
...state.formData,
passwordConf: action.passwordConf
},
}
case 'PROFILE_SUCCESS':
return initial.formData
default:
return state
}
}
const message = (state = initial.message, action) => { const message = (state = initial.message, action) => {
switch (action.type) { switch (action.type) {
case 'AUTH_ERROR': case 'AUTH_ERROR':
case 'PROFILE_ERROR': case 'PROFILE_ERROR':
return action.message return action.message
case 'LOGOUT': case 'LOGOUT':
return ''
case 'PROFILE_SUCCESS': case 'PROFILE_SUCCESS':
case '@@router/LOCATION_CHANGE':
return '' return ''
default: default:
return state return state
} }
} }
const messages = (state = initial.messages, action) => {
switch (action.type) {
case 'AUTH_ERRORS':
return action.messages
case 'LOGOUT':
case 'PROFILE_SUCCESS':
case '@@router/LOCATION_CHANGE':
return []
default:
return state
}
}
const user = (state = initial.user, action) => { const user = (state = initial.user, action) => {
switch (action.type) { switch (action.type) {
case 'AUTH_ERROR': case 'AUTH_ERROR':
@ -37,40 +87,11 @@ const user = (state = initial.user, action) => {
} }
} }
const formData = (state = initial.formData, action) => {
switch (action.type) {
case 'UPDATE_FORMDATA_EMAIL':
return {
formData: {
...state.formData,
email: action.email
},
}
case 'UPDATE_FORMDATA_USERNAME':
return {
formData: {
...state.formData,
username: action.username
},
}
case 'UPDATE_FORMDATA_PASSWORD':
return {
formData: {
...state.formData,
password: action.password
},
}
case 'PROFILE_SUCCESS':
return initial.formData
default:
return state
}
}
const reducers = combineReducers({ const reducers = combineReducers({
message,
user,
formData, formData,
message,
messages,
user,
}) })
export default reducers export default reducers

View File

@ -1,5 +1,6 @@
export default { export default {
message: '', message: '',
messages: [],
user: { user: {
id: '', id: '',
username: '', username: '',
@ -12,7 +13,8 @@ export default {
formData: { formData: {
username: '', username: '',
email: '', email: '',
password: '' password: '',
passwordConf: '',
} }
}, },
} }

16
package-lock.json generated
View File

@ -4059,8 +4059,7 @@
}, },
"jsbn": { "jsbn": {
"version": "0.1.1", "version": "0.1.1",
"bundled": true, "bundled": true
"optional": true
}, },
"json-schema": { "json-schema": {
"version": "0.2.3", "version": "0.2.3",
@ -4726,6 +4725,11 @@
"minimalistic-assert": "1.0.0" "minimalistic-assert": "1.0.0"
} }
}, },
"hashids": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/hashids/-/hashids-1.1.4.tgz",
"integrity": "sha512-U/fnTE3edW0AV92ZI/BfEluMZuVcu3MDOopsN7jS+HqDYcarQo8rXQiWlsBlm0uX48/taYSdxRsfzh2HRg5Z6w=="
},
"hawk": { "hawk": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz",
@ -9311,6 +9315,14 @@
"react-side-effect": "1.1.3" "react-side-effect": "1.1.3"
} }
}, },
"react-key-index": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/react-key-index/-/react-key-index-0.1.1.tgz",
"integrity": "sha1-gxnk8JYa5EqOsKT3bkwhDvbTnNo=",
"requires": {
"hashids": "1.1.4"
}
},
"react-redux": { "react-redux": {
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.6.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.6.tgz",

View File

@ -7,6 +7,7 @@
"react": "^16.2.0", "react": "^16.2.0",
"react-dom": "^16.2.0", "react-dom": "^16.2.0",
"react-helmet": "^5.2.0", "react-helmet": "^5.2.0",
"react-key-index": "^0.1.1",
"react-redux": "^5.0.6", "react-redux": "^5.0.6",
"react-router-dom": "^4.2.2", "react-router-dom": "^4.2.2",
"react-router-redux": "^5.0.0-alpha.9", "react-router-redux": "^5.0.0-alpha.9",