diff --git a/README.md b/README.md index 21f5dd1..413c413 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # Final Project Starter A starter repository for the Final Project. + +Hi, my name is Steven Fiero and this is the repo for my final project for the ACA Advanced class. diff --git a/client/package.json b/client/package.json index a7340d9..f78a4c3 100644 --- a/client/package.json +++ b/client/package.json @@ -18,5 +18,6 @@ "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" - } + }, + "proxy": "http://localhost:3001" } diff --git a/client/src/App.js b/client/src/App.js index d03f4a8..c178e0a 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,13 +1,119 @@ import React, { Component } from 'react'; -import { BrowserRouter, Route } from 'react-router-dom'; +import { BrowserRouter, Route, Switch } from 'react-router-dom'; +import axios from 'axios'; import './App.css'; import SignUpSignIn from './SignUpSignIn'; import TopNavbar from './TopNavbar'; import Secret from './Secret'; -import axios from 'axios'; + class App extends Component { + constructor() { + super(); + + this.state = { + signUpSignInError: '', + authenticated: localStorage.getItem('token') + }; + } + + + handleSignUp(credentials) { + // Handler is responsible for taking the credentials, verifying the information + // and submitting the request to the API to signup a new user. + const { username, password, confirmPassword } = credentials; + if (!username.trim() || !password.trim() || password.trim() !== confirmPassword.trim()) { + this.setState({ + signUpSignInError: 'Must Provide All Fields' + }); + } else { + axios.post('/api/signup', credentials) + .then(resp => { + const { token } = resp.data; + localStorage.setItem('token', token); + + this.setState({ + signUpSignInError: '', + authenticated: token + }); + }); + } + } + + + handleSignIn(credentials) { + // Handler is responsible for taking the credentials, verifying the information + // and submitting the request to the API to signin an existing user. + const { username, password } = credentials; + if (!username.trim() || !password.trim()) { + this.setState({ + signUpSignInError: 'Must Provide All Fields' + }); + } else { + axios.post('/api/signin', credentials) + .then(resp => { + const { token } = resp.data; + localStorage.setItem('token', token); + this.setState({ + signUpSignInError: '', + authenticated: token + }); + }); + } + } + + + handleSignOut() { + localStorage.removeItem('token'); + this.setState({ + authenticated: false + }); + } + + + renderSignUpSignIn() { + return ; + } + + + renderApp() { + // Returning routing logic to be rendered + // When user successfully signsup / signsin, the token is updated in state, + // which causes a re-render and then the renderApp method is called, + // which allows the user access to the application. + return ( +
+ +

I am protected!

} /> + +

NOT FOUND!

} /> +
+
+ ); + } + + + // Looking into state for authenticated value to be set, if so render App. + // If state does not have authenticated value, render SignUpSignIn. + render() { + return ( + +
+ + {this.state.authenticated ? this.renderApp() : this.renderSignUpSignIn()} +
+
+ ); + } } + export default App; diff --git a/client/src/Secret.js b/client/src/Secret.js index dc30585..f370cda 100644 --- a/client/src/Secret.js +++ b/client/src/Secret.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import axios from 'axios'; + class Secret extends Component { constructor() { super(); @@ -10,21 +11,24 @@ class Secret extends Component { }; } + componentDidMount() { axios.get('/api/secret', { + // Passing token via authorization header headers: { authorization: localStorage.getItem('token') } }) .then(resp => { this.setState({ - ...this.state, message: resp.data }); }) + /* eslint no-console: 0 */ .catch(err => console.log(err)); } + render() { return (

{this.state.message}

@@ -32,4 +36,5 @@ class Secret extends Component { } } + export default Secret; diff --git a/client/src/SignIn.js b/client/src/SignIn.js new file mode 100644 index 0000000..88782a7 --- /dev/null +++ b/client/src/SignIn.js @@ -0,0 +1,75 @@ +import React, { Component, PropTypes } from 'react'; +import { FormGroup, ControlLabel, FormControl, Button } from 'react-bootstrap'; + + +class SignIn extends Component { + constructor() { + super(); + + this.state = { + username: '', + password: '', + }; + } + + + handleSubmit(event) { + // handleSubmit method is invoked when the form is submitted + event.preventDefault(); + + this.props.onSignIn({ + username: this.state.username, + password: this.state.password + }); + } + + + handleChange(event) { + const { name, value } = event.target; + + this.setState({ + [name]: value + }); + } + + + render() { + return ( +
+ + Username + this.handleChange(event)} + placeholder="Enter Username" + value={this.state.username} + /> + + + + Password + this.handleChange(event)} + placeholder="Enter Password" + value={this.state.password} + /> + + + +
+ ); + } +} + + +SignIn.propTypes = { + onSignIn: PropTypes.func.isRequired +}; + + +export default SignIn; diff --git a/client/src/SignUp.js b/client/src/SignUp.js index 7b533c2..658fde2 100644 --- a/client/src/SignUp.js +++ b/client/src/SignUp.js @@ -1,6 +1,7 @@ import React, { Component, PropTypes } from 'react'; import { FormGroup, ControlLabel, FormControl, Button } from 'react-bootstrap'; + class SignUp extends Component { constructor() { super(); @@ -8,11 +9,13 @@ class SignUp extends Component { this.state = { username: '', password: '', - confirmPassword: '', - } + confirmPassword: '' + }; } + handleSubmit(event) { + // handleSubmit method is invoked when the form is submitted event.preventDefault(); this.props.onSignUp({ @@ -22,15 +25,16 @@ class SignUp extends Component { }); } + handleChange(event) { const { name, value } = event.target; - this.setState(prev => ({ - ...prev, + this.setState({ [name]: value - })); + }); } + render() { return (
@@ -69,14 +73,16 @@ class SignUp extends Component { +
); } } + SignUp.propTypes = { onSignUp: PropTypes.func.isRequired }; + export default SignUp; diff --git a/client/src/SignUpSignIn.js b/client/src/SignUpSignIn.js index 8bbfc4f..5541fd8 100644 --- a/client/src/SignUpSignIn.js +++ b/client/src/SignUpSignIn.js @@ -1,6 +1,8 @@ import React, { Component, PropTypes } from 'react'; import { Tabs, Tab, Row, Col, Alert } from 'react-bootstrap'; import SignUp from './SignUp'; +import SignIn from './SignIn'; + class SignUpSignIn extends Component { @@ -22,18 +24,21 @@ class SignUpSignIn extends Component { - Sign In + - ) + ); } } + SignUpSignIn.propTypes = { error: PropTypes.string, - onSignUp: PropTypes.func.isRequired + onSignUp: PropTypes.func.isRequired, + onSignIn: PropTypes.func.isRequired }; + export default SignUpSignIn; diff --git a/client/src/TopNavbar.js b/client/src/TopNavbar.js index e5d36b9..6d5f027 100644 --- a/client/src/TopNavbar.js +++ b/client/src/TopNavbar.js @@ -1,6 +1,7 @@ import React, { PropTypes } from 'react'; import { Navbar, Nav, NavItem } from 'react-bootstrap'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; + const TopNavbar = (props) => { return ( @@ -20,16 +21,17 @@ const TopNavbar = (props) => { - - : null + : null } ); -} +}; + TopNavbar.propTypes = { onSignOut: PropTypes.func.isRequired, - showNavItems: PropTypes.bool.isRequired + showNavItems: PropTypes.string.isRequired }; + export default TopNavbar; diff --git a/src/controllers/ListsController.js b/src/controllers/ListsController.js new file mode 100644 index 0000000..244a9a1 --- /dev/null +++ b/src/controllers/ListsController.js @@ -0,0 +1,81 @@ +import ListModel from '../models/ListModel'; + + +const ListController = { + + + // Declare a (GET = list) route. + list(req, res, next) { + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .exec() + .then(list => { + return res.json(list); + }) + .catch(err => next(err)); + }, + + + // Declare a (GET/:id = show) route + show(req, res, next) { + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .exec() + .then(list => { + return res.json(list); + }) + .catch(err => next(err)); + }, + + + // Declare a (POST = create) route. + create(req, res, next ) { + // In create method, assigning the title based on what is received + // from the request body, and user_id from the req.user. + const list = new List({ title: req.body.title, userId: req.user._id }); + list + .save() + .then(newList => res.json(newList)) + .catch(err => next(err)); + }, + + + // Declare a (PUT = update) route. + update(req, res, next) { + // In update method, finding the List based on the _id and user_id. + // This means a user will not be able to update a list they do not own. + ListModel.find({ _id: req.params.id, userId: req.user._id }) + .exec() + .then(list => { + if (!list) { + return next('No List Found'); + } + + list.title = req.params.title; + return list.save(); + }) + .then(list => { + return req.json(list); + }) + .catch(err => next(err)); + }, + + + // Declare a DELETE (remove) route. + remove(req, res, next) { + const itemId = req.params.item_id; + + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .exec() + .then(list => { + list.items.id(itemId).remove(); + + return list.save(); + }) + .then(list => { + return req.json(list.items.id(itemId)); + }) + .catch(err => next(err)); + } +}; + + +export default ListController; diff --git a/src/controllers/ListsItemsController.js b/src/controllers/ListsItemsController.js new file mode 100644 index 0000000..7d7c1b0 --- /dev/null +++ b/src/controllers/ListsItemsController.js @@ -0,0 +1,101 @@ +import ListModel from '../models/ListModel'; + + +const ListsItemsController = { + + + // Declare a (GET = list) route. + list(req, res, next) { + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .exec() + .then(list => { + return res.json(list); + }) + .catch(err => next(err)); + }, + + + // Declare a (GET/:id = show) route. + show(req, res, next) { + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .exec() + .then(list => { + return res.json(list); + }) + .catch(err => next(err)); + }, + + + // Declare a (POST = create) route. + create(req, res, next) { + // Find the list by it's id, and ensure that the current user owns the list + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .then(list => { + // Create a new object that represents an item + const item = { + text: req.body.text, + completed: false + }; + + // Add the item to the lists items array + list.items.push(item); + + // Save the list + return list.save(); + }) + .then(list => { + // Grab the newly created item, which is the last item in the array + const newItem = list.items[list.items.length - 1]; + + // Return that item + return res.json(newItem); + }) + .catch(err => next(err)); + }, + + + // Declare a (PUT = update) route. + update(req, res, next) { + const itemId = req.params.item_id; + + // Find the list by it's id, and ensure that the current user owns the list + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .exec() + .then(list => { + // Find the item by it's _id + const item = list.items.id(itemId); + + // Update the item if new attributes are sent, or use the current attributes + item.text = req.body.text || item.text; + item.completed = req.body.completed || item.completed; + + return list.save(); + }) + .then(list => { + // Return the updated item + return req.json(list.items.id(itemId)); + }) + .catch(err => next(err)); + }, + + + // Declare a (DELETE = remove) route. + remove(req, res, next) { + const itemId = req.params.item_id; + + ListModel.find({ _id: req.params.list_id, userId: req.user._id }) + .exec() + .then(list => { + list.items.id(itemId).remove(); + + return list.save(); + }) + .then(list => { + return req.json(list.items.id(itemId)); + }) + .catch(err => next(err)); + } +}; + + +export default ListsItemsController; diff --git a/src/index.js b/src/index.js index 96dee02..2fff3f5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,24 +1,47 @@ // dotenv allows us to declare environment variables in a .env file, \ // find out more here https://github.com/motdotla/dotenv -require("dotenv").config(); -const express = require("express"); -const bodyParser = require("body-parser"); -const mongoose = require("mongoose"); -const passport = require("passport"); +require('dotenv').config(); +const express = require('express'); +const bodyParser = require('body-parser'); +const mongoose = require('mongoose'); +const passport = require('passport'); +import ListRoutes from './routes/ListRoutes'; +import ListItemRoutes from './routes/ListItemRoutes'; + +// Require our custom strategies +require('./services/passport'); + + +/* eslint no-console: 0 */ mongoose.Promise = global.Promise; mongoose - .connect("mongodb://localhost/todo-list-app") - .then(() => console.log("[mongoose] Connected to MongoDB")) - .catch(() => console.log("[mongoose] Error connecting to MongoDB")); + .connect('mongodb://localhost/advanced-final-project-starter') + .then(() => console.log('[mongoose] Connected to MongoDB')) + .catch(() => console.log('[mongoose] Error connecting to MongoDB')); const app = express(); -const authenticationRoutes = require("./routes/AuthenticationRoutes"); +const authenticationRoutes = require('./routes/AuthenticationRoutes'); app.use(bodyParser.json()); -// app.use(authenticationRoutes); +app.use(authenticationRoutes); + +const authStrategy = passport.authenticate('authStrategy', { session: false }); + +app.use(authStrategy, ListRoutes); +app.use(authStrategy, ListItemRoutes); +// Applied middleware to routes, and all ListRoutes will be protected by authStrategy. +// This means a user will not have access unless they are logged into the application. + +/* eslint no-unused-vars: 0 */ +app.get('/api/secret', authStrategy, function (req, res, next) { + res.send(`The current user is ${req.user.username}`); +}); + + const port = process.env.PORT || 3001; + app.listen(port, () => { console.log(`Listening on port:${port}`); }); diff --git a/src/models/ListModel.js b/src/models/ListModel.js new file mode 100644 index 0000000..79454fc --- /dev/null +++ b/src/models/ListModel.js @@ -0,0 +1,33 @@ +import mongoose, { Schema } from 'mongoose'; + + +const listSchema = new Schema({ + title: { + type: String, + required: true + }, + + userId: { + type: Schema.Types.ObjectId, + required: true + }, + + // Items do not have their own ItemModel.js file, but will instead exist + // inside of a List document in the Lists collection. + // Declare a new property items, and set its value to an array. + // Mongoose will store an array of items. + items: [{ + text: { + type: String, + required: true + }, + + completed: { + type: Boolean, + required: true + } + }] +}); + + +export default mongoose.model('List', listSchema); diff --git a/src/models/UserModel.js b/src/models/UserModel.js index 47c5e39..48107ef 100644 --- a/src/models/UserModel.js +++ b/src/models/UserModel.js @@ -1,6 +1,6 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; -const bcrypt = require('bcrypt'); + const userSchema = new Schema({ username: { diff --git a/src/routes/AuthenticationRoutes.js b/src/routes/AuthenticationRoutes.js index 611413a..3479dd9 100644 --- a/src/routes/AuthenticationRoutes.js +++ b/src/routes/AuthenticationRoutes.js @@ -3,3 +3,65 @@ const router = express.Router(); const jwt = require('jwt-simple'); const User = require('../models/UserModel'); const bcrypt = require('bcrypt'); +const passport = require('passport'); + + +// Require our custom strategies +require('../services/passport'); + + +// Authentication +const signinStrategy = passport.authenticate('signinStrategy', { session: false }); + + +// Helper method to create token for a user +function tokenForUser(user) { + const timestamp = new Date().getTime(); + return jwt.encode({ userId: user.id, iat: timestamp }, process.env.SECRET); +} + + +/* eslint no-unused-vars: 0 */ +router.post('/api/signin', signinStrategy, function (req, res, next) { + res.json({ token: tokenForUser(req.user)}); +}); + + +router.post('/api/signup', function (req, res, next) { + // Grab the username and password from the request body + const { username, password } = req.body; + + // If no username or password was supplied return an error + if (!username || !password) { + return res.status(422) + .json({ error: 'You must provide a username and password' }); + } + + // Look for a user with the current user name + User.findOne({ username }).exec() + .then((existingUser) => { + // If the user exists, return an error on sign up + if (existingUser) { + return res.status(422).json({ error: 'Username is in use' }); + } + + // If the user does not exist, create the user + // Use bcrypt to hash their password (Never save plain text passwords!) + bcrypt.hash(password, 10, function (err, hashedPassword) { + if (err) { + return next(err); + } + + // Create a new user with the supplied username and the hashed password + const user = new User({ username, password: hashedPassword }); + + // Save and return the user + user.save() + .then(newUser => res.json({ token: tokenForUser(newUser) })); + }); + }) + .catch(err => next(err)); +}); + + +module.exports = router; diff --git a/src/routes/ListItemRoutes.js b/src/routes/ListItemRoutes.js new file mode 100644 index 0000000..3da61e4 --- /dev/null +++ b/src/routes/ListItemRoutes.js @@ -0,0 +1,24 @@ +import express from 'express'; +import ListsItemsController from '../controllers/ListsItemsController'; +const router = express.Router(); + + +// Declare GET routes. +router.get('/lists', ListsItemsController.list); + +router.get('/lists/:list_id/items', ListsItemsController.show); + + +// Declare a POST (create) route. +router.post('/lists/:list_id/items', ListsItemsController.create); + + +// Declare a PUT (update) route. +router.put('/lists/:list_id/items/:item_id', ListsItemsController.update); + + +// Declare a DELETE (remove) route. +router.delete('/lists/:list_id/items/:item_id', ListsItemsController.remove); + + +export default router; diff --git a/src/routes/ListRoutes.js b/src/routes/ListRoutes.js new file mode 100644 index 0000000..74e7c64 --- /dev/null +++ b/src/routes/ListRoutes.js @@ -0,0 +1,24 @@ +import express from 'express'; +import ListController from '../controllers/ListsController'; +const router = express.Router(); + + +// Declare GET routes. +router.get('/lists', ListController.list); + +router.get('/lists/:list_id', ListController.show); + + +// Declare a POST (create) route. +router.post('/lists', ListController.create); + + +// Declare a PUT (update) route. +router.put('/lists/:id', ListController.update); + + +// Declare a DELETE (remove) route. +router.delete('/lists/:id', ListController.remove); + + +export default router; diff --git a/src/services/passport.js b/src/services/passport.js index 5680076..f55105c 100644 --- a/src/services/passport.js +++ b/src/services/passport.js @@ -3,3 +3,64 @@ const passport = require('passport'); const User = require('../models/UserModel'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const LocalStrategy = require('passport-local'); + + +const signinStrategy = new LocalStrategy(function (username, password, done) { + User.findOne({ username }).exec() + .then(user => { + // If there is no user found, call done with a `null` argument, signifying + // no error and `false` signifying that the signin failed + if (!user) { + return done(null, false); + } + + bcrypt.compare(password, user.password, function (err, isMatch) { + // If there is an error, call done with the error + if (err) { + return done(err, false); + } + + // If the passwords do not match, call done with a `null` argument, signifying + // no error and `false` signifying that the signin failed + if (!isMatch) { + return done(null, false); + } + + // If there are no errors and the passwords match, + // call done with a `null` argument, signifying no error + // and with the now signed in user + return done(null, user); + }); + }) + .catch(err => done(err, false)); +}); + + +// Setup options for JwtStrategy +const jwtOptions = { + // Get the secret from the environment + secretOrKey: process.env.SECRET, + // Tell the strategy where to find the token in the request + jwtFromRequest: ExtractJwt.fromHeader('authorization') +}; + + +// Create JWT strategy +// This will take the token and decode it to +// extract the information we have stored in it +const authStrategy = new JwtStrategy(jwtOptions, function (payload, done) { + User.findById(payload.userId, function (err, user) { + if (err) { return done(err, false); } + + if (user) { + done(null, user); + } else { + done(null, false); + } + }); +}); + + +// Tell passport to use this strategy +passport.use('authStrategy', authStrategy); +passport.use('signinStrategy', signinStrategy);