diff --git a/client/src/actions/authActions.js b/client/src/actions/authActions.js
new file mode 100644
index 0000000..7f75389
--- /dev/null
+++ b/client/src/actions/authActions.js
@@ -0,0 +1,109 @@
+import axios from 'axios';
+
+import { getErrors } from './errorActions';
+
+import {
+ USER_LOADING,
+ USER_LOADED,
+ AUTH_ERROR,
+ LOGIN_SUCCESS,
+ LOGIN_FAIL,
+ LOGOUT_SUCCESS,
+ REGISTER_SUCCESS,
+ REGISTER_FAIL
+} from '../actions/types';
+
+export const loadUser = () => (dispatch, getState) => {
+ dispatch({ type: USER_LOADING });
+
+ axios
+ .get('http://localhost:5000/api/auth/user', tokenConfig(getState))
+ .then(res => {
+ dispatch({
+ type: USER_LOADED,
+ payload: res.data
+ })
+ })
+ .catch(err => {
+ dispatch(getErrors(err.response.data, err.response.status));
+ dispatch({
+ type: AUTH_ERROR
+ })
+ })
+}
+
+export const register = ({ name, email, password }) => dispatch => {
+ const config = {
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }
+
+ // Request body
+ const body = JSON.stringify({ name, email, password });
+
+ axios
+ .post('http://localhost:5000/api/users', body, config)
+ .then(res => {
+ dispatch({
+ type: REGISTER_SUCCESS,
+ payload: res.data
+ })
+ })
+ .catch(err => {
+ dispatch(getErrors(err.response.data, err.response.status, REGISTER_FAIL));
+ dispatch({
+ type: REGISTER_FAIL
+ })
+ })
+}
+
+export const login = ({ email, password }) => dispatch => {
+ // Headers
+ const config = {
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }
+
+ // Request body
+ const body = JSON.stringify({ email, password });
+
+ axios
+ .post('http://localhost:5000/api/auth', body, config)
+ .then(res => {
+ dispatch({
+ type: LOGIN_SUCCESS,
+ payload: res.data
+ })
+ })
+ .catch(err => {
+ dispatch(getErrors(err.response.data, err.response.status, LOGIN_FAIL));
+ dispatch({
+ type: LOGIN_FAIL
+ })
+ })
+}
+
+export const logout = () => {
+ return {
+ type: LOGOUT_SUCCESS
+ }
+}
+
+export const tokenConfig = getState => {
+ // Get token from local storage
+ const token = getState().auth.token;
+
+ const config = {
+ headers: {
+ "Content-Type": "application/json"
+ }
+ }
+
+ if (token) {
+ config.headers['x-auth-token'] = token;
+ }
+
+ return config
+}
\ No newline at end of file
diff --git a/client/src/actions/types.js b/client/src/actions/types.js
index 26eb46b..4f024d5 100644
--- a/client/src/actions/types.js
+++ b/client/src/actions/types.js
@@ -1,2 +1,11 @@
export const GET_ERRORS = 'GET_ERRORS';
-export const CLEAR_ERRORS = 'CLEAR_ERRORS';
\ No newline at end of file
+export const CLEAR_ERRORS = 'CLEAR_ERRORS';
+
+export const USER_LOADING = 'USER_LOADING';
+export const USER_LOADED = 'USER_LOADED';
+export const AUTH_ERROR = 'AUTH_ERROR';
+export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
+export const LOGIN_FAIL = 'LOGIN_FAIL';
+export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
+export const REGISTER_SUCCESS = 'REGISTER_SUCCESS';
+export const REGISTER_FAIL = 'REGISTER_FAIL';
\ No newline at end of file
diff --git a/client/src/components/AppNavbar.js b/client/src/components/AppNavbar.js
index 4d0dd44..f0f3547 100644
--- a/client/src/components/AppNavbar.js
+++ b/client/src/components/AppNavbar.js
@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React from 'react';
import {
Collapse,
Navbar,
@@ -13,8 +13,14 @@ import {
// how to use React Router with reactstrap
import { NavLink as RRNavLink } from 'react-router-dom';
+import { connect } from 'react-redux';
+
import PropTypes from 'prop-types';
+import RegisterModal from './auth/RegisterModal';
+import LoginModal from './auth/LoginModal';
+import Logout from './auth/Logout';
+
class AppNavbar extends React.Component {
state = {
isOpen: false // for the collapsible menu
@@ -27,6 +33,33 @@ class AppNavbar extends React.Component {
}
render() {
+ const { isAuthenticated, user } = this.props.auth;
+
+ const guestLinks = (
+ <>
+
+
+
+
+
+
+ >
+ );
+
+ const authLinks = (
+ <>
+
+
+ {user ? `Welcome, ${user.name}` : ''}
+
+
+
+
+
+ >
+ );
+
+
return (
@@ -35,7 +68,9 @@ class AppNavbar extends React.Component {
@@ -45,4 +80,12 @@ class AppNavbar extends React.Component {
}
}
-export default AppNavbar;
\ No newline at end of file
+AppNavbar.propTypes = {
+ auth: PropTypes.object.isRequired
+}
+
+const mapStateToProps = state => ({
+ auth: state.auth // from rootReducer
+});
+
+export default connect(mapStateToProps, null)(AppNavbar);
\ No newline at end of file
diff --git a/client/src/components/auth/LoginModal.js b/client/src/components/auth/LoginModal.js
new file mode 100644
index 0000000..68254be
--- /dev/null
+++ b/client/src/components/auth/LoginModal.js
@@ -0,0 +1,139 @@
+import React from 'react';
+import {
+ Button,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ Form,
+ FormGroup,
+ Label,
+ Input,
+ NavLink,
+ Alert
+} from 'reactstrap';
+
+import PropTypes from 'prop-types';
+
+import { connect } from 'react-redux';
+
+import { login } from '../../actions/authActions';
+import { clearErrors } from '../../actions/errorActions';
+
+import { LOGIN_FAIL } from '../../actions/types';
+
+class LoginModal extends React.Component {
+ state = {
+ modal: false,
+ email: '',
+ password: '',
+ msg: null
+ }
+
+ componentDidUpdate(prevProps) {
+ const { error, isAuthenticated } = this.props;
+ if (error !== prevProps.error) {
+ if (error.id === LOGIN_FAIL) {
+ this.setState({
+ msg: error.msg.msg
+ });
+ }
+ else {
+ this.setState({ msg: null })
+ }
+ }
+
+ // If authenticated, close modal
+ if (this.state.modal) {
+ if (isAuthenticated) {
+ this.toggle();
+ }
+ }
+ }
+
+ toggle = () => {
+ // clearErrors() prevents the previous error message from showing up
+ // when the modal is re-opened
+ this.props.clearErrors();
+ this.setState({
+ modal: !this.state.modal
+ });
+ }
+ // Using [e.target.name] allows onChange to be used with multiple state vars
+ onChange = e => {
+ this.setState({
+ [e.target.name]: e.target.value
+ })
+ }
+
+ onSubmit = e => {
+ e.preventDefault();
+
+ const { email, password } = this.state;
+
+ const user = {
+ email,
+ password
+ }
+
+ this.props.login(user);
+ }
+
+ render() {
+ return (
+
+
Login
+
+
+ Login
+
+ { this.state.msg ? {this.state.msg} : null }
+
+
+
+
+ );
+ }
+}
+
+LoginModal.propTypes = {
+ isAuthenticated: PropTypes.bool,
+ error: PropTypes.object.isRequired,
+ login: PropTypes.func.isRequired,
+ clearErrors: PropTypes.func.isRequired
+}
+
+const mapStateToProps = state => ({
+ isAuthenticated: state.auth.isAuthenticated, // from authReducer
+ error: state.error
+});
+
+export default connect(
+ mapStateToProps,
+ { login, clearErrors })(LoginModal);
diff --git a/client/src/components/auth/Logout.js b/client/src/components/auth/Logout.js
new file mode 100644
index 0000000..6e717d0
--- /dev/null
+++ b/client/src/components/auth/Logout.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux';
+
+import { logout } from '../../actions/authActions';
+import { NavLink } from 'reactstrap';
+
+class Logout extends React.Component {
+ render() {
+ return (
+ <>
+ Logout
+ >
+ );
+ }
+}
+
+Logout.propTypes = {
+ logout: PropTypes.func.isRequired
+}
+
+export default connect(null, { logout })(Logout);
\ No newline at end of file
diff --git a/client/src/components/auth/RegisterModal.js b/client/src/components/auth/RegisterModal.js
new file mode 100644
index 0000000..f78c379
--- /dev/null
+++ b/client/src/components/auth/RegisterModal.js
@@ -0,0 +1,149 @@
+import React from 'react';
+import {
+ Button,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ Form,
+ FormGroup,
+ Label,
+ Input,
+ NavLink,
+ Alert
+} from 'reactstrap';
+
+import PropTypes from 'prop-types';
+
+import { connect } from 'react-redux';
+
+import { register } from '../../actions/authActions';
+import { clearErrors } from '../../actions/errorActions';
+import { REGISTER_FAIL } from '../../actions/types';
+
+class RegisterModal extends React.Component {
+ state = {
+ isOpen: false,
+ name: '',
+ email: '',
+ password: '',
+ msg: null
+ }
+
+ componentDidUpdate(prevProps) {
+ const { error, isAuthenticated } = this.props;
+ if (error !== prevProps.error) {
+ if (error.id === REGISTER_FAIL) {
+ this.setState({
+ msg: error.msg.msg
+ });
+ }
+ else {
+ this.setState({ msg: null })
+ }
+ }
+
+ // If authenticated, close modal
+ if (this.state.isOpen) {
+ if (isAuthenticated) {
+ this.toggle();
+ }
+ }
+ }
+
+ toggle = () => {
+ // clearErrors() prevents the previous error message from showing up
+ // when the modal is re-opened
+ this.props.clearErrors();
+ this.setState({
+ isOpen: !this.state.isOpen
+ });
+ }
+ // Using [e.target.name] allows onChange to be used with multiple state vars
+ onChange = e => {
+ this.setState({
+ [e.target.name]: e.target.value
+ })
+ }
+
+ onSubmit = e => {
+ e.preventDefault();
+
+ const { name, email, password } = this.state;
+
+ const newUser = {
+ name,
+ email,
+ password
+ };
+
+ this.props.register(newUser);
+ }
+
+ render() {
+ return (
+
+
Register
+
+
+ Register
+
+ { this.state.msg ? {this.state.msg} : null }
+
+
+
+
+ );
+ }
+}
+
+RegisterModal.propTypes = {
+ isAuthenticated: PropTypes.bool,
+ error: PropTypes.object.isRequired,
+ register: PropTypes.func.isRequired,
+ clearErrors: PropTypes.func.isRequired
+}
+
+const mapStateToProps = state => ({
+ isAuthenticated: state.auth.isAuthenticated, // from authReducer
+ error: state.error
+});
+
+export default connect(
+ mapStateToProps,
+ { register, clearErrors })(RegisterModal);
diff --git a/client/src/reducers/authReducer.js b/client/src/reducers/authReducer.js
new file mode 100644
index 0000000..9a1b14e
--- /dev/null
+++ b/client/src/reducers/authReducer.js
@@ -0,0 +1,57 @@
+import {
+ USER_LOADED,
+ USER_LOADING,
+ AUTH_ERROR,
+ LOGIN_SUCCESS,
+ LOGIN_FAIL,
+ LOGOUT_SUCCESS,
+ REGISTER_SUCCESS,
+ REGISTER_FAIL
+} from '../actions/types';
+
+const initialState = {
+ token: localStorage.getItem('token'),
+ isAuthenticated: null,
+ isLoading: false,
+ user: null
+}
+
+export default function(state = initialState, action) {
+ switch (action.type) {
+ case USER_LOADING:
+ return {
+ ...state,
+ isLoading: true
+ };
+ case USER_LOADED:
+ return {
+ ...state,
+ isAuthenticated: true,
+ isLoading: false,
+ user: action.payload
+ };
+ case LOGIN_SUCCESS:
+ case REGISTER_SUCCESS:
+ localStorage.setItem('token', action.payload.token);
+ return {
+ ...state,
+ ...action.payload, // user AND token
+ isAuthenticated: true,
+ isLoading: false
+ }
+ case AUTH_ERROR:
+ case LOGIN_FAIL:
+ case LOGOUT_SUCCESS:
+ case REGISTER_FAIL:
+ localStorage.removeItem('token');
+ return {
+ ...state,
+ token: null,
+ user: null,
+ isAuthenticated: false,
+ isLoading: false
+ }
+ default:
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/client/src/reducers/rootReducer.js b/client/src/reducers/rootReducer.js
index 6c9e4fc..995718e 100644
--- a/client/src/reducers/rootReducer.js
+++ b/client/src/reducers/rootReducer.js
@@ -1,7 +1,9 @@
import { combineReducers } from 'redux';
import errorReducer from './errorReducer';
+import authReducer from './authReducer';
export default combineReducers({
- error: errorReducer
+ error: errorReducer,
+ auth: authReducer
});
\ No newline at end of file
diff --git a/middleware/auth.js b/middleware/auth.js
new file mode 100644
index 0000000..208dd93
--- /dev/null
+++ b/middleware/auth.js
@@ -0,0 +1,21 @@
+const jwt = require('jsonwebtoken');
+
+function auth(req, res, next) {
+ const token = req.header('x-auth-token');
+
+ // Check for token
+ if (!token) { res.status(401).json({ msg: 'No token; authorization denied.'}); return }
+
+ try {
+ const decoded = jwt.verify(token, process.env.jwtSecret);
+
+ // Add user from payload
+ req.user = decoded;
+ next();
+ } catch (e) {
+ res.status(400).json({ msg: 'Token is not valid.'});
+ return;
+ }
+}
+
+module.exports = auth;
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 288a36b..602c0cf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -446,6 +446,15 @@
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
+ "cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "requires": {
+ "object-assign": "^4",
+ "vary": "^1"
+ }
+ },
"crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@@ -1264,6 +1273,11 @@
"integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==",
"dev": true
},
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+ },
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
diff --git a/package.json b/package.json
index 4a489a3..259283e 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"concurrently": "^5.3.0",
+ "cors": "^2.8.5",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.10.3"
diff --git a/routes/api/auth.js b/routes/api/auth.js
new file mode 100644
index 0000000..b5f1ace
--- /dev/null
+++ b/routes/api/auth.js
@@ -0,0 +1,59 @@
+const express = require('express');
+const router = express.Router();
+
+const bcrypt = require('bcryptjs');
+const jwt = require('jsonwebtoken');
+const auth = require('../../middleware/auth');
+
+const User = require('../../models/User');
+
+// Authenticate user
+router.post('/', (req, res) => {
+ const { email, password } = req.body;
+
+ // Simple validation, but insecure!
+ if (!email || !password) {
+ return res.status(400).json({ msg: 'Please enter all fields.' });
+ }
+
+ // Check for existing user
+ User.findOne({ email })
+ .then(user => {
+ if (!user) {
+ return res.status(400).json({ msg: 'User does not exist.' });
+ }
+
+ // Validate password
+ bcrypt.compare(password, user.password)
+ .then(isMatch => {
+ if (!isMatch) return res.status(400).json({ msg: 'Invalid credentials.' });
+
+ jwt.sign(
+ { id: user.id },
+ process.env.jwtSecret,
+ { expiresIn: 3600 }, // expires in an hour
+ (err, token) => {
+ if (err) { throw err; }
+
+ res.json({
+ token,
+ user: {
+ id: user.id,
+ name: user.name,
+ email: user.email
+ }
+ })
+ }
+ )
+ })
+ })
+});
+
+// Get user data
+router.get('/user/', auth, (req, res) => {
+ User.findById(req.user.id)
+ .select('-password')
+ .then(user => res.json(user));
+})
+
+module.exports = router;
\ No newline at end of file
diff --git a/server.js b/server.js
index b2aba85..a7fe0f2 100644
--- a/server.js
+++ b/server.js
@@ -1,7 +1,9 @@
const express = require('express');
const mongoose = require('mongoose');
+const cors = require('cors');
const userRoutes = require('./routes/api/users');
+const authRoutes = require('./routes/api/auth');
const app = express();
@@ -16,6 +18,22 @@ mongoose
const port = process.env.PORT || 5000;
+// See https://daveceddia.com/access-control-allow-origin-cors-errors-in-react-express/
+// for source of the whitelist code
+const whitelist = ['http://localhost:3000'];
+const corsOptions = {
+ origin: function(origin, callback) {
+ if (whitelist.indexOf(origin) !== -1) {
+ callback(null, true);
+ }
+ else {
+ callback(new Error(`${origin} not allowed by CORS`));
+ }
+ }
+}
+
+app.use(cors(corsOptions));
app.use('/api/users', userRoutes);
+app.use('/api/auth', authRoutes);
app.listen(port, () => console.log(`Server started on port ${port}.`));
\ No newline at end of file