├── .babelrc ├── .env ├── .eslintrc.json ├── .gitignore ├── .sequelizerc ├── README.md ├── api ├── app.js ├── config │ └── config.js ├── controllers │ ├── authController.js │ ├── todoItemsController.js │ └── todosController.js ├── middlewares │ ├── auth.js │ └── authorize.js ├── migrations │ ├── 20191207170237-create-user.js │ ├── 20200126150323-create-todo.js │ └── 20200126150424-create-todo-item.js ├── models │ ├── index.js │ ├── todo.js │ ├── todoitem.js │ └── user.js ├── routes │ └── index.js └── utils │ ├── index.js │ └── sendEmail.js ├── build └── app.js ├── client ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── index.html │ └── todo.jpg ├── src │ ├── actions │ │ ├── auth.js │ │ ├── index.js │ │ └── todos.js │ ├── apiCall │ │ └── index.js │ ├── components │ │ ├── App.js │ │ ├── auth │ │ │ ├── SignIn.jsx │ │ │ ├── SignUp.jsx │ │ │ └── Styles.js │ │ ├── landing │ │ │ ├── Landing.jsx │ │ │ └── Styles.js │ │ ├── layouts │ │ │ ├── Footer.js │ │ │ ├── Layout.jsx │ │ │ ├── NavBar.jsx │ │ │ └── Styles.js │ │ └── todos │ │ │ ├── CreateTask.jsx │ │ │ ├── CreateTodoModal.jsx │ │ │ ├── ListTasks.jsx │ │ │ ├── ListTodos.jsx │ │ │ ├── RenderTodos.jsx │ │ │ └── Styles.js │ ├── context │ │ ├── authContext.js │ │ ├── createDataContext.jsx │ │ └── todosContext.js │ ├── index.js │ └── reducers │ │ ├── auth.js │ │ └── todos.js └── yarn.lock ├── package-lock.json └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DB_DEV_URL="postgres://postgres:awa@localhost:5432/todo-dev" 2 | DB_TESt_URL="postgres://postgres:awa@localhost:5432/todo-test" 3 | DB_PROD_URL= 4 | JWT_SECRET="ourjwtsecret" 5 | SENDGRID_API_KEY="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" 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "mocha": true 7 | }, 8 | "extends": "airbnb-base", 9 | "settings": { 10 | "import/resolver": { 11 | "node": { 12 | "extensions": [ 13 | ".js", 14 | ".jsx" 15 | ] 16 | } 17 | } 18 | }, 19 | "parser": "babel-eslint", 20 | "parserOptions": { 21 | "ecmaVersion": 6, 22 | "sourceType": "module", 23 | "ecmaFeatures": { 24 | "modules": true, 25 | "jsx": true 26 | } 27 | }, 28 | "plugins": ["import", "react"], 29 | "rules": { 30 | "comma-dangle": 0 31 | } 32 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'config': path.resolve('./api/config', 'config.js'), 5 | 'models-path': path.resolve('./api', 'models'), 6 | 'seeders-path': path.resolve('./api', 'seeders'), 7 | 'migrations-path': path.resolve('./api', 'migrations'), 8 | }; 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Best To Do List Application 2 | A ReactJS Hooks/Context, Styled-components, Bootstrap, Node, Express, Sequelize and Postgres To Do List application companion repo to YouTube tutorial here: [https://www.youtube.com/watch?v=gK-M8u08f8Y&list=PL9g7odpg28fTgk7O1zXixYt0uzqBs9jCm](https://www.youtube.com/watch?v=gK-M8u08f8Y&list=PL9g7odpg28fTgk7O1zXixYt0uzqBs9jCm) 3 | 4 | ## How to test the application. 5 | 6 | - Clone the repo 7 | - In the root directory of the application, run `npm install`. 8 | - Start the api server by running the command `npm run dev`. 9 | - After running that, open another terminal tab and run `cd client` to change into the client directory. 10 | - While in the client, run `npm install` again to install dependencies for the client side. 11 | - And lastly, `npm start` to completely start the application. 12 | -------------------------------------------------------------------------------- /api/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import bodyParser from 'body-parser'; 3 | import cors from 'cors'; 4 | import { routes } from './routes'; 5 | 6 | const app = express(); 7 | 8 | app.use(cors({ credentials: true, origin: true })); 9 | 10 | app.use(bodyParser.urlencoded({ extended: false })); 11 | app.use(bodyParser.json()); 12 | 13 | routes(app); 14 | 15 | const port = process.env.PORT || 3002; 16 | app.listen(port, () => console.log(`Server ready at http://localhost:${port}`)); 17 | -------------------------------------------------------------------------------- /api/config/config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | module.exports = { 4 | development: { 5 | database: 'todo-dev', 6 | use_env_variables: 'DB_DEV_URL', 7 | dialect: 'postgres', 8 | }, 9 | test: { 10 | database: 'todo-test', 11 | use_env_variables: 'DB_TEST_URL', 12 | dialect: 'postgres', 13 | }, 14 | production: { 15 | database: 'todo-prod', 16 | use_env_variables: 'DB_PROD_URL', 17 | dialect: 'postgres', 18 | }, 19 | } -------------------------------------------------------------------------------- /api/controllers/authController.js: -------------------------------------------------------------------------------- 1 | import validator from 'validator'; 2 | import sendEmail from '../utils/sendEmail'; 3 | import models from '../models'; 4 | import { hashPassword, jwtToken, comparePassword } from '../utils'; 5 | 6 | const { User } = models; 7 | 8 | const auth = { 9 | async signUp(req, res, next) { 10 | try { 11 | const { name, email, password } = req.body; 12 | const hash = hashPassword(password); 13 | const user = await User.create({ name, email, password: hash }); 14 | const token = jwtToken.createToken(user); 15 | const { id } = user; 16 | return res.status(201).send({ token, user: { id, name, email } }); 17 | } catch (e) { 18 | return next(new Error(e)); 19 | } 20 | }, 21 | 22 | async signIn(req, res, next) { 23 | try { 24 | const { email, password } = req.body; 25 | const user = await User.findOne({ where: { email } }); 26 | if (user && comparePassword(password, user.password)) { 27 | const { name, id } = user; 28 | const token = jwtToken.createToken(user); 29 | return res.status(200).send({ token, user: { id, name, email } }); 30 | } 31 | return res.status(400).send({ error: 'invalid email/password combination ' }); 32 | } catch (e) { 33 | return next(new Error(e)); 34 | } 35 | }, 36 | 37 | async sendResetLink(req, res, next) { 38 | try { 39 | const { email } = req.body; 40 | const user = await User.findOne({ where: { email } }); 41 | if (!email) { 42 | return res.status(400).send({ error: 'Email is required' }); 43 | } 44 | if (!validator.isEmail(email)) { 45 | return res.status(400).send({ error: 'Invalid email' }); 46 | } 47 | if (!user) { 48 | return res.status(404).send({ error: 'User not found' }); 49 | } 50 | const token = jwtToken.createToken(user); 51 | const link = `${req.protocol}://localhost:5000/reset_password/${token}`; 52 | await sendEmail( 53 | email, 54 | 'noreply@todo.com', 55 | 'Best To Do password reset', 56 | ` 57 |
Click the link below to reset your password

58 |
${link}
59 | ` 60 | ); 61 | return res.status(200).send({ message: 'Password reset link has been successfully sent to your inbox' }); 62 | } catch (e) { 63 | return next(new Error(e)); 64 | } 65 | }, 66 | 67 | async resetPassword(req, res, next) { 68 | try { 69 | const { password } = req.body; 70 | const { token } = req.params; 71 | const decoded = jwtToken.verifyToken(token); 72 | const hash = hashPassword(password); 73 | const updatedUser = await User.update( 74 | { password: hash }, 75 | { 76 | where: { id: decoded.userId }, 77 | returning: true, 78 | plain: true, 79 | } 80 | ); 81 | const { id, name, email } = updatedUser[1]; 82 | return res.status(200).send({ token, user: { id, name, email } }); 83 | } catch (e) { 84 | return next(new Error(e)); 85 | } 86 | } 87 | }; 88 | 89 | export default auth; 90 | -------------------------------------------------------------------------------- /api/controllers/todoItemsController.js: -------------------------------------------------------------------------------- 1 | import models from '../models'; 2 | 3 | const { TodoItem, Todo } = models; 4 | 5 | const todoItems = { 6 | async create(req, res, next) { 7 | try { 8 | const { text, todoId } = req.body; 9 | // Validation 10 | if (!text) { return res.status(400).send({ error: 'Text is required' }); } 11 | if (!todoId) { return res.status(400).send({ error: 'todoId is required' }); } 12 | const item = await TodoItem.create({ text, todoId }); 13 | return res.status(201).send(item); 14 | } catch (e) { 15 | return next(new Error(e)); 16 | } 17 | }, 18 | 19 | async fetchAll(req, res, next) { 20 | try { 21 | const { todoId } = req.params; 22 | // Validation 23 | if (!todoId) { return res.status(400).send({ error: 'todoId is required' }); } 24 | const items = await TodoItem.findAll({ 25 | where: { todoId }, 26 | include: [{ 27 | model: Todo, 28 | as: 'todo' 29 | }], 30 | }); 31 | return res.status(200).send(items); 32 | } catch (e) { 33 | return next(new Error(e)); 34 | } 35 | }, 36 | 37 | async fetchOne(req, res, next) { 38 | try { 39 | const { todoItemId } = req.params; 40 | // Validation 41 | if (!todoItemId) { return res.status(400).send({ error: 'todoItemId is required' }); } 42 | const items = await TodoItem.findOne({ 43 | where: { id: todoItemId }, 44 | include: [{ 45 | model: Todo, 46 | as: 'todo' 47 | }], 48 | }); 49 | return res.status(200).send(items); 50 | } catch (e) { 51 | return next(new Error(e)); 52 | } 53 | }, 54 | 55 | async update(req, res, next) { 56 | try { 57 | const { text, isCompleted } = req.body; 58 | const { todoItemId } = req.params; 59 | // Validation 60 | if (!todoItemId) { return res.status(400).send({ error: 'todoItemId is required' }); } 61 | const item = await TodoItem.findOne({ 62 | where: { id: todoItemId }, 63 | }); 64 | if (!item) { 65 | return res.status(404).send({ error: 'Item does not exist' }); 66 | } 67 | const updatedItem = await TodoItem.update( 68 | { text: text || item.text, isCompleted }, 69 | { 70 | where: { id: req.params.todoItemId }, 71 | returning: true, 72 | plain: true, 73 | } 74 | ); 75 | return res.status(200).send(updatedItem[1]); 76 | } catch (e) { 77 | return next(new Error(e)); 78 | } 79 | }, 80 | 81 | async delete(req, res, next) { 82 | try { 83 | const { todoItemId } = req.params; 84 | // Validation 85 | if (!todoItemId) { return res.status(400).send({ error: 'todoItemId is required' }); } 86 | const item = await TodoItem.findOne({ 87 | where: { id: todoItemId }, 88 | }); 89 | if (!item) { 90 | return res.status(404).send({ error: 'Item does not exist' }); 91 | } 92 | await item.destroy(); 93 | return res.status(200).send({}); 94 | } catch (e) { 95 | return next(new Error(e)); 96 | } 97 | } 98 | }; 99 | 100 | export default todoItems; 101 | -------------------------------------------------------------------------------- /api/controllers/todosController.js: -------------------------------------------------------------------------------- 1 | import models from '../models'; 2 | 3 | const { Todo, TodoItem } = models; 4 | 5 | const todos = { 6 | async create({ body, decoded }, res, next) { 7 | try { 8 | const { title } = body; 9 | const { userId } = decoded; 10 | const todo = await Todo.create({ title, userId }); 11 | return res.status(201).send(todo); 12 | } catch (e) { 13 | return next(new Error(e)); 14 | } 15 | }, 16 | 17 | async fetchAll({ decoded }, res, next) { 18 | try { 19 | const myTodos = await Todo.findAll({ 20 | where: { userId: decoded.userId }, 21 | include: [{ 22 | model: TodoItem, 23 | as: 'todoItems' 24 | }], 25 | }); 26 | return res.status(200).send(myTodos); 27 | } catch (e) { 28 | return next(new Error(e)); 29 | } 30 | }, 31 | 32 | async fetchOne({ params, decoded }, res, next) { 33 | try { 34 | const myTodo = await Todo.findOne({ 35 | where: { id: params.todoId, userId: decoded.userId }, 36 | include: [{ 37 | model: TodoItem, 38 | as: 'todoItems' 39 | }], 40 | }); 41 | if (!myTodo) { 42 | return res.status(404).send({ error: 'Todo not found' }); 43 | } 44 | return res.status(200).send(myTodo); 45 | } catch (e) { 46 | return next(new Error(e)); 47 | } 48 | }, 49 | 50 | async update({ body, decoded, params }, res, next) { 51 | try { 52 | const todo = await Todo.findOne({ where: { id: params.todoId, userId: decoded.userId } }); 53 | if (!todo) { 54 | return res.status(400).send({ error: 'Wrong todo id' }); 55 | } 56 | const updatedTodo = await Todo.update({ title: body.title || todo.title }, 57 | { 58 | where: { id: todo.id }, 59 | returning: true, 60 | plain: true 61 | },); 62 | return res.status(200).send(updatedTodo[1]); 63 | } catch (e) { 64 | return next(new Error(e)); 65 | } 66 | }, 67 | 68 | async delete({ params, decoded }, res, next) { 69 | try { 70 | const todo = await Todo.findOne({ where: { id: params.todoId, userId: decoded.userId } }); 71 | if (!todo) { 72 | return res.status(400).send({ error: 'Wrong todo id' }); 73 | } 74 | await todo.destroy(); 75 | return res.status(200).send({}); 76 | } catch (e) { 77 | return next(new Error(e)); 78 | } 79 | } 80 | }; 81 | 82 | export default todos; 83 | -------------------------------------------------------------------------------- /api/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | import validator from 'validator'; 3 | import { User } from '../models'; 4 | 5 | export default async (req, res, next) => { 6 | const { name, email, password } = req.body; 7 | if (!validator.isEmail) { 8 | return res.status(400).send({ error: 'invalid email address' }); 9 | } 10 | if (!email) { 11 | return res.status(400).send({ error: 'email is required' }); 12 | } 13 | if (!name) { 14 | return res.status(400).send({ error: 'name is required' }); 15 | } 16 | if (!password) { 17 | return res.status(400).send({ error: 'password is required' }); 18 | } 19 | const user = await User.findOne({ where: { email } }); 20 | if (user) { 21 | return res.status(409).send({ error: 'user exist already' }); 22 | } 23 | next(); 24 | }; 25 | -------------------------------------------------------------------------------- /api/middlewares/authorize.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { config } from 'dotenv'; 3 | import models from '../models'; 4 | 5 | const { User } = models; 6 | 7 | config(); 8 | 9 | export default (req, res, next) => { 10 | if (!req.headers.authorization) { 11 | return res.status(401).send({ error: 'Unauthorized' }); 12 | } 13 | 14 | const token = req.headers.authorization.split(' ')[1]; 15 | return jwt.verify(token, process.env.JWT_SECRET, { expiresIn: '24h' }, (error, decoded) => { 16 | if (error) { 17 | return res.status(401).send({ error }); 18 | } 19 | req.decoded = decoded; 20 | return User.findByPk(decoded.userId) 21 | .then((user) => { 22 | if (!user) { 23 | return res.status(401).send({ error: 'User does not exist' }); 24 | } 25 | return next(); 26 | }); 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /api/migrations/20191207170237-create-user.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => queryInterface.createTable('Users', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER 9 | }, 10 | name: { 11 | type: Sequelize.STRING 12 | }, 13 | email: { 14 | type: Sequelize.STRING, 15 | unique: true, 16 | }, 17 | password: { 18 | type: Sequelize.STRING 19 | }, 20 | createdAt: { 21 | allowNull: false, 22 | type: Sequelize.DATE 23 | }, 24 | updatedAt: { 25 | allowNull: false, 26 | type: Sequelize.DATE 27 | } 28 | }), 29 | down: (queryInterface) => queryInterface.dropTable('Users') 30 | }; 31 | -------------------------------------------------------------------------------- /api/migrations/20200126150323-create-todo.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => queryInterface.createTable('Todos', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER 9 | }, 10 | title: { 11 | type: Sequelize.STRING 12 | }, 13 | userId: { 14 | type: Sequelize.INTEGER, 15 | allowNull: false, 16 | references: { 17 | model: 'Users', 18 | key: 'id' 19 | } 20 | }, 21 | createdAt: { 22 | allowNull: false, 23 | type: Sequelize.DATE 24 | }, 25 | updatedAt: { 26 | allowNull: false, 27 | type: Sequelize.DATE 28 | } 29 | }), 30 | down: (queryInterface) => queryInterface.dropTable('Todos') 31 | }; 32 | -------------------------------------------------------------------------------- /api/migrations/20200126150424-create-todo-item.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => queryInterface.createTable('TodoItems', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER 9 | }, 10 | text: { 11 | type: Sequelize.STRING, 12 | allowNull: false, 13 | }, 14 | todoId: { 15 | type: Sequelize.INTEGER, 16 | allowNull: false, 17 | references: { 18 | model: 'Todos', 19 | key: 'id' 20 | } 21 | }, 22 | isCompleted: { 23 | type: Sequelize.BOOLEAN, 24 | defaultValue: false, 25 | }, 26 | createdAt: { 27 | allowNull: false, 28 | type: Sequelize.DATE 29 | }, 30 | updatedAt: { 31 | allowNull: false, 32 | type: Sequelize.DATE 33 | } 34 | }), 35 | down: (queryInterface) => queryInterface.dropTable('TodoItems') 36 | }; 37 | -------------------------------------------------------------------------------- /api/models/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import Sequelize from 'sequelize'; 4 | import envVars from '../config/config'; 5 | 6 | const basename = path.basename(__filename); 7 | const env = process.env.NODE_ENV || 'development'; 8 | const config = envVars[env]; 9 | const db = {}; 10 | 11 | let sequelize; 12 | if (config.use_env_variable) { 13 | sequelize = new Sequelize(process.env[config.use_env_variable], config); 14 | } else { 15 | sequelize = new Sequelize(config.database, config.username, config.password, config); 16 | } 17 | 18 | fs 19 | .readdirSync(__dirname) 20 | .filter((file) => (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js')) 21 | .forEach((file) => { 22 | const model = sequelize.import(path.join(__dirname, file)); 23 | db[model.name] = model; 24 | }); 25 | 26 | Object.keys(db).forEach((modelName) => { 27 | if (db[modelName].associate) { 28 | db[modelName].associate(db); 29 | } 30 | }); 31 | 32 | db.sequelize = sequelize; 33 | db.Sequelize = Sequelize; 34 | 35 | module.exports = db; 36 | -------------------------------------------------------------------------------- /api/models/todo.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const Todo = sequelize.define('Todo', { 3 | title: DataTypes.STRING, 4 | userId: DataTypes.INTEGER 5 | }, {}); 6 | Todo.associate = (models) => { 7 | // associations can be defined here 8 | Todo.belongsTo(models.User, { 9 | as: 'user', 10 | foreignKey: 'userId' 11 | }); 12 | Todo.hasMany(models.TodoItem, { 13 | as: 'todoItems', 14 | foreignKey: 'todoId' 15 | }); 16 | }; 17 | return Todo; 18 | }; 19 | -------------------------------------------------------------------------------- /api/models/todoitem.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const TodoItem = sequelize.define('TodoItem', { 3 | text: DataTypes.STRING, 4 | todoId: DataTypes.INTEGER, 5 | isCompleted: DataTypes.BOOLEAN 6 | }, {}); 7 | TodoItem.associate = (models) => { 8 | // associations can be defined here 9 | TodoItem.belongsTo(models.Todo, { 10 | as: 'todo', 11 | foreignKey: 'todoId' 12 | }); 13 | }; 14 | return TodoItem; 15 | }; 16 | -------------------------------------------------------------------------------- /api/models/user.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (sequelize, DataTypes) => { 3 | const User = sequelize.define('User', { 4 | name: DataTypes.STRING, 5 | email: DataTypes.STRING, 6 | password: DataTypes.STRING 7 | }, {}); 8 | User.associate = (models) => { 9 | // associations can be defined here 10 | User.hasMany(models.Todo, { 11 | as: 'todos', 12 | foreignKey: 'userId', 13 | }); 14 | }; 15 | return User; 16 | }; 17 | -------------------------------------------------------------------------------- /api/routes/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import auth from '../controllers/authController'; 3 | import validateAuth from '../middlewares/auth'; 4 | import todos from '../controllers/todosController'; 5 | import authorize from '../middlewares/authorize'; 6 | import todoItems from '../controllers/todoItemsController'; 7 | 8 | export const routes = (app) => { 9 | app.get('/', (req, res) => res.send({ message: 'Welcome to Todo API' })); 10 | 11 | app.post('/api/auth/sign_up', validateAuth, auth.signUp); 12 | app.post('/api/auth/sign_in', auth.signIn); 13 | app.post('/api/auth/forgot_password', auth.sendResetLink); 14 | app.post('/reset_password/:token', auth.resetPassword); 15 | 16 | app.post('/api/todos', authorize, todos.create); 17 | app.get('/api/todos', authorize, todos.fetchAll); 18 | app.get('/api/todos/:todoId', authorize, todos.fetchOne); 19 | app.put('/api/todos/:todoId', authorize, todos.update); 20 | app.delete('/api/todos/:todoId', authorize, todos.delete); 21 | 22 | app.post('/api/todoItems', todoItems.create); 23 | app.get('/api/todos/:todoId/todoItems', todoItems.fetchAll); 24 | app.get('/api/todoItems/:todoItemId', todoItems.fetchOne); 25 | app.put('/api/todoItems/:todoItemId', todoItems.update); 26 | app.delete('/api/todoItems/:todoItemId', todoItems.delete); 27 | }; 28 | -------------------------------------------------------------------------------- /api/utils/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import jwt from 'jsonwebtoken'; 3 | import bcrypt from 'bcryptjs'; 4 | import { config } from 'dotenv'; 5 | 6 | config(); 7 | 8 | export const jwtToken = { 9 | createToken({ id, email }) { 10 | return jwt.sign( 11 | { userId: id, email }, 12 | process.env.JWT_SECRET, 13 | { expiresIn: '24h' } 14 | ); 15 | }, 16 | verifyToken(token) { 17 | const decoded = jwt.verify(token, process.env.JWT_SECRET, { expiresIn: '24h' }); 18 | return decoded; 19 | } 20 | }; 21 | 22 | export const hashPassword = (password) => bcrypt.hashSync(password, 10); 23 | export const comparePassword = (password, hash) => bcrypt.compareSync(password, hash); 24 | -------------------------------------------------------------------------------- /api/utils/sendEmail.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import sgMail from '@sendgrid/mail'; 3 | 4 | config(); 5 | 6 | sgMail.setApiKey(process.env.SENDGRID_API_KEY); 7 | 8 | const sendEmail = (receiver, source, subject, content) => { 9 | try { 10 | const data = { 11 | to: receiver, 12 | from: source, 13 | subject, 14 | html: content, 15 | }; 16 | return sgMail.send(data); 17 | } catch (e) { 18 | return new Error(e); 19 | } 20 | }; 21 | 22 | export default sendEmail; 23 | -------------------------------------------------------------------------------- /build/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var _express = _interopRequireDefault(require("express")); 3 | 4 | var _bodyParser = _interopRequireDefault(require("body-parser")); 5 | 6 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; } 7 | 8 | var app = (0, _express["default"])(); 9 | app.use(_bodyParser["default"].urlencoded({ 10 | extended: false 11 | })); 12 | app.use(_bodyParser["default"].json()); 13 | var port = process.env.app.PORT || 5000; 14 | app.listen(port, function () { 15 | return console.log("Server ready at http://localhost:".concat(port)); 16 | }); -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "axios": "^0.19.2", 10 | "react": "^16.12.0", 11 | "react-dom": "^16.12.0", 12 | "react-scripts": "3.3.1", 13 | "styled-components": "^5.0.1" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 20 | React App 21 | 33 | 34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /client/public/todo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieudonneAwa/react-nodejs-todo-list/fd7cf1f300be1c0476ad7c5b83d8e88c228d8f1b/client/public/todo.jpg -------------------------------------------------------------------------------- /client/src/actions/auth.js: -------------------------------------------------------------------------------- 1 | import apiCall from '../apiCall'; 2 | 3 | export const signUp = (dispatch) => async (user) => { 4 | try { 5 | dispatch({ type: 'SIGNUP_USER_LOADING' }); 6 | const res = await apiCall('/auth/sign_up', 'post', user); 7 | dispatch({ type: 'SIGNUP_USER_SUCCESS', payload: res.data }); 8 | return res; 9 | } catch (err) { 10 | return dispatch({ type: 'SIGNUP_USER_FAILURE', payload: err.response.data }); 11 | } 12 | }; 13 | 14 | export const setCurrentUser = (dispatch) => (Cookies, jwtDecode) => { 15 | try { 16 | dispatch({ type: 'SET_CURRENT_USER_LOADING' }); 17 | const payload = jwtDecode(Cookies.get('token')); 18 | dispatch({ type: 'SET_CURRENT_USER_SUCCESS', payload }); 19 | } catch (err) { 20 | dispatch({ type: 'SET_CURRENT_USER_FAILURE', payload: err }) 21 | } 22 | } 23 | 24 | export const signIn = (dispatch) => async (user) => { 25 | try { 26 | dispatch({ type: 'SIGNIN_USER_LOADING' }); 27 | const res = await apiCall('/auth/sign_in', 'post', user); 28 | dispatch({ type: 'SIGNIN_USER_SUCCESS', payload: res.data }); 29 | return res; 30 | } catch (err) { 31 | return dispatch({ type: 'SIGNIN_USER_FAILURE', payload: err.response.data }) 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /client/src/actions/index.js: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './todos'; 3 | -------------------------------------------------------------------------------- /client/src/actions/todos.js: -------------------------------------------------------------------------------- 1 | import apiCall from "../apiCall"; 2 | 3 | export const createTodo = (dispatch) => async (todo, Cookies) => { 4 | try { 5 | dispatch({ type: 'CREATE_TODO_LOADING' }); 6 | const res = await apiCall('/todos', 'post', todo, Cookies.get('token')); 7 | dispatch({ type: 'CREATE_TODO_SUCCESS', payload: res.data }) 8 | return res; 9 | } catch (err) { 10 | return dispatch({ type: 'CREATE_TODO_FAILURE' }) 11 | } 12 | }; 13 | 14 | export const fetchTodos = (dispatch) => async (Cookies) => { 15 | try { 16 | dispatch({ type: 'FETCH_TODOS_LOADING' }); 17 | const res = await apiCall('/todos', 'get', null, Cookies.get('token')); 18 | dispatch({ type: 'FETCH_TODOS_SUCCESS', payload: res.data }) 19 | return res; 20 | } catch (err) { 21 | if (err && err.response) { 22 | return dispatch({ type: 'FETCH_TODOS_FAILURE', payload: err.response.data }) 23 | } 24 | } 25 | } 26 | 27 | export const deleteTodo = (dispatch) => async (todoId, Cookies) => { 28 | try { 29 | dispatch({ type: 'DELETE_TODO_LOADING' }); 30 | const res = await apiCall(`/todos/${todoId}`, 'delete', null, Cookies.get('token')); 31 | dispatch({ type: 'DELETE_TODO_SUCCESS', payload: todoId }); 32 | return res; 33 | } catch (err) { 34 | console.log(err) 35 | return dispatch({ type: 'DELETE_TODO_FAILURE', payload: err.response.data }) 36 | } 37 | } 38 | 39 | export const createTask = (dispatch) => async (task, Cookies) => { 40 | try { 41 | dispatch({ type: 'CREATE_TASK_LOADING' }); 42 | const res = await apiCall('/todoItems', 'post', task, Cookies.get('token')); 43 | dispatch({ type: 'CREATE_TASK_SUCCESS', payload: res.data }); 44 | return res; 45 | } catch (err) { 46 | return dispatch({ type: 'CREATE_TASK_FAILURE' }) 47 | } 48 | } 49 | 50 | export const markTaskAsDone = (dispatch) => async (task) => { 51 | try { 52 | dispatch({ type: 'MARK_AS_DONE_LOADING' }); 53 | const res = await apiCall(`/todoItems/${task.id}`, 'put', task); 54 | dispatch({ type: 'MARK_AS_DONE_SUCCESS', payload: res.data }); 55 | return res; 56 | } catch (err) { 57 | return dispatch({ type: 'MARK_AS_DONE_FAILURE', payload: err.response.data }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/apiCall/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const composeToken = (token) => token ? { Authorization: `Bearer ${token}` } : {}; 4 | 5 | const apiCall = (url, method, body = {}, token = '') => axios({ 6 | method, 7 | url: `http://localhost:3002/api${url}`, 8 | data: body, 9 | headers: { 10 | ...composeToken(token) 11 | } 12 | }); 13 | 14 | export default apiCall; 15 | -------------------------------------------------------------------------------- /client/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Route, BrowserRouter as Router } from 'react-router-dom'; 3 | import Cookies from 'js-cookie'; 4 | import Landing from './landing/Landing'; 5 | import { Provider, Context } from '../context/authContext'; 6 | import SignUp from './auth/SignUp'; 7 | import SignIn from './auth/SignIn'; 8 | import ListTodos from './todos/ListTodos'; 9 | 10 | function App() { 11 | const { signUp, signIn } = useContext(Context); 12 | 13 | return ( 14 |
15 | 16 | 17 | ( { 18 | const res = await signUp(user); 19 | if (res) { 20 | Cookies.set('token', res.data.token); 21 | history.push('/todos'); 22 | } 23 | }} />) 24 | } /> 25 | ( { 26 | const res = await signIn(user); 27 | if (res) { 28 | Cookies.set('token', res.data.token); 29 | history.push('/todos'); 30 | } 31 | }} />)} /> 32 | ()} /> 33 | 34 |
35 | ); 36 | } 37 | 38 | export default () => { 39 | return ( 40 | 41 | 42 | 43 | ) 44 | }; 45 | -------------------------------------------------------------------------------- /client/src/components/auth/SignIn.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import classnames from 'classnames'; 3 | import { NavLink } from 'react-router-dom'; 4 | import Layout from '../layouts/Layout'; 5 | import { AuthFormWrapper } from './Styles'; 6 | import { Context } from '../../context/authContext'; 7 | 8 | const SignIn = ({ signIn }) => { 9 | const { state } = useContext(Context); 10 | const [email, setemail] = useState(''); 11 | const [password, setpassword] = useState(''); 12 | const [emailErr, setemailErr] = useState(''); 13 | const [passwordErr, setpasswordErr] = useState(''); 14 | 15 | const handleChange = (e, name) => { 16 | const user = {}; 17 | const emailRegEx = RegExp( 18 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 19 | ); 20 | user[name] = e.target.value; 21 | // validations 22 | switch (name) { 23 | case 'email': 24 | setemail(user.email); 25 | !emailRegEx.test(user.email) ? setemailErr('Invalid Email!') : setemailErr(''); 26 | break; 27 | case 'password': 28 | setpassword(user.password); 29 | user.password.length < 8 ? setpasswordErr('Password must be at least 8 characters!') : setpasswordErr(''); 30 | break; 31 | default: 32 | break; 33 | } 34 | } 35 | 36 | const handleSignIn = async (e) => { 37 | e.preventDefault(); 38 | if (email && password && !emailErr && !passwordErr) { 39 | await signIn({ email, password }); 40 | } 41 | } 42 | 43 | return ( 44 | 45 | 46 |

Sign In

47 | {state.signInErr &&
48 | {state.signInErr} 49 |
} 50 |
51 |
52 | 53 | handleChange(e, 'email')} 63 | /> 64 | {emailErr && {emailErr}} 65 |
66 |
67 | 68 | handleChange(e, 'password')} 78 | /> 79 | {passwordErr && {passwordErr}} 80 |
81 | 82 |
83 |

84 | Don't yet have an account? Sign Up 85 |

86 |
87 |
88 | ) 89 | } 90 | 91 | export default SignIn 92 | -------------------------------------------------------------------------------- /client/src/components/auth/SignUp.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import classnames from 'classnames'; 3 | import { NavLink } from 'react-router-dom'; 4 | import Layout from '../layouts/Layout'; 5 | import { AuthFormWrapper } from './Styles'; 6 | import { Context } from '../../context/authContext'; 7 | 8 | const SignUp = ({ signUp }) => { 9 | const { state } = useContext(Context); 10 | const [name, setname] = useState(''); 11 | const [email, setemail] = useState(''); 12 | const [password, setpassword] = useState(''); 13 | const [confPassword, setconfPassword] = useState(''); 14 | const [nameErr, setnameErr] = useState(''); 15 | const [emailErr, setemailErr] = useState(''); 16 | const [passwordErr, setpasswordErr] = useState(''); 17 | const [confPasswordErr, setconfPasswordErr] = useState(''); 18 | 19 | const handleChange = (e, name) => { 20 | const user = {}; 21 | const emailRegEx = RegExp( 22 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 23 | ); 24 | user[name] = e.target.value; 25 | // validations 26 | switch (name) { 27 | case 'name': 28 | setname(user.name); 29 | user.name.length < 3 ? setnameErr('Name must be at least 3 characters!') : setnameErr(''); 30 | break; 31 | case 'email': 32 | setemail(user.email); 33 | !emailRegEx.test(user.email) ? setemailErr('Invalid Email!') : setemailErr(''); 34 | break; 35 | case 'password': 36 | setpassword(user.password); 37 | user.password.length < 8 ? setpasswordErr('Password must be at least 8 characters!') : setpasswordErr(''); 38 | break; 39 | case 'confPassword': 40 | setconfPassword(user.confPassword); 41 | user.confPassword !== password ? setconfPasswordErr('Passwords do not match!') : setconfPasswordErr(''); 42 | break; 43 | default: 44 | break; 45 | } 46 | } 47 | 48 | const handleSignUp = async (e) => { 49 | e.preventDefault(); 50 | if (name && email && password && confPassword && !nameErr && !emailErr && !passwordErr && !confPasswordErr) { 51 | await signUp({ name, email, password }); 52 | } 53 | } 54 | 55 | return ( 56 | 57 | 58 |

Create an Account

59 | {state.signUpErr &&
60 | {state.signUpErr} 61 |
} 62 |
63 |
64 | 65 | handleChange(e, 'name')} 75 | /> 76 | {nameErr && {nameErr}} 77 |
78 |
79 | 80 | handleChange(e, 'email')} 90 | /> 91 | {emailErr && {emailErr}} 92 |
93 |
94 | 95 | handleChange(e, 'password')} 105 | /> 106 | {passwordErr && {passwordErr}} 107 |
108 |
109 | 110 | handleChange(e, 'confPassword')} 120 | /> 121 | {confPasswordErr && {confPasswordErr}} 122 |
123 | 124 |
125 |

126 | Already have an account? Sign In 127 |

128 |
129 |
130 | ) 131 | } 132 | 133 | export default SignUp 134 | -------------------------------------------------------------------------------- /client/src/components/auth/Styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const AuthFormWrapper = styled.div` 4 | width: 40%; 5 | border-radius: 5px; 6 | margin: 70px auto; 7 | `; 8 | -------------------------------------------------------------------------------- /client/src/components/landing/Landing.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Main } from './Styles' 3 | import NavBar from '../layouts/NavBar'; 4 | 5 | const Landing = () => { 6 | return ( 7 | <> 8 | 9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 | 19 | ) 20 | } 21 | 22 | export default Landing; 23 | -------------------------------------------------------------------------------- /client/src/components/landing/Styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Main = styled.div` 4 | background-image: url('/todo.jpg'); 5 | filter: saturate(200%); 6 | background-image: 7 | linear-gradient(rgba(1, 1, 1, 0.52), rgba(8,8,8, 0.73)), 8 | url('/todo.jpg'); 9 | width: 100%; 10 | height: 100vh; 11 | background-size: cover; 12 | 13 | .container-fluid { 14 | height: 100%; 15 | } 16 | .row { 17 | margin-top: 300px; 18 | 19 | .btn { 20 | border: 1px solid #06A82A; 21 | background: #FFFFFF; 22 | font-size: x-large; 23 | border-radius: 5px; 24 | color: #06A82A; 25 | font-weight: 600; 26 | } 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /client/src/components/layouts/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Wrapper = styled.footer` 5 | width: 100%; 6 | height: 70px; 7 | border-top: 1px solid #ccc; 8 | background-color: #fff; 9 | position: absolute; 10 | bottom: 0; 11 | `; 12 | 13 | const Footer = () => { 14 | return ( 15 | 16 |

© 2020 FullStack Valley.

17 |
18 | ); 19 | } 20 | 21 | export default Footer 22 | -------------------------------------------------------------------------------- /client/src/components/layouts/Layout.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import NavBar from './NavBar'; 4 | import Footer from './Footer'; 5 | 6 | const Wrapper = styled.div` 7 | width: 100%; 8 | min-height: 100vh; 9 | position: relative; 10 | `; 11 | 12 | const Layout = ({ children }) => { 13 | return ( 14 | 15 | 16 | {children} 17 |