├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── lerna.json ├── package.json └── src ├── client ├── App.js ├── components │ ├── CheckIfLoggedIn │ │ └── CheckIfLoggedIn.js │ ├── EmailSent │ │ ├── EmailSent.js │ │ └── EmailSent.less │ ├── FormInputField │ │ └── FormInputField.js │ ├── GuestRoute │ │ └── GuestRoute.js │ ├── Layouts │ │ ├── GuestLayout.js │ │ ├── Layouts.less │ │ └── LoggedLayout.js │ ├── LoginForm │ │ ├── LoginForm.js │ │ └── LoginForm.less │ ├── PrivateRoute │ │ └── PrivateRoute.js │ └── RegisterForm │ │ ├── RegisterForm.js │ │ └── RegisterForm.less ├── config │ ├── configureStore.js │ └── createApolloClient.js ├── graphql │ ├── graphql.js │ └── mutations │ │ └── mutations.js ├── index.js ├── package.json ├── pages │ ├── Dashboard │ │ ├── Dashboard.js │ │ └── Dashboard.less │ ├── Login │ │ ├── Login.js │ │ └── Login.less │ ├── NotFound │ │ ├── NotFound.js │ │ └── NotFound.less │ └── Register │ │ ├── Register.js │ │ └── Register.less ├── public │ ├── favicon.png │ ├── icons │ │ ├── close.svg │ │ ├── toastr-error.svg │ │ ├── toastr-separator.svg │ │ └── toastr-success.svg │ ├── images │ │ ├── logo.png │ │ └── menu.svg │ ├── index.html │ └── manifest.json ├── store │ ├── actions │ │ ├── actions.js │ │ └── auth │ │ │ ├── auth.js │ │ │ ├── removeAuthUser.js │ │ │ ├── setAuthUser.js │ │ │ └── setFirstAuthState.js │ └── reducers │ │ ├── auth.js │ │ └── reducers.js ├── validators │ ├── user.js │ └── validators.js └── webpack.config.js └── server ├── config └── loggerConfig.js ├── graphql ├── directives │ ├── auth.js │ ├── directives.js │ ├── guest.js │ └── role.js ├── resolvers │ ├── resolvers.js │ └── user.js ├── schemas │ ├── root.js │ ├── schemas.js │ └── user.js └── validators │ ├── user.js │ └── validators.js ├── helpers ├── auth.js └── token.js ├── index.js ├── models ├── models.js ├── token.js └── user.js ├── package.json └── utils └── sendEmail.js /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV = "development" 2 | SESSION_NAME = "name" 3 | SESSION_SECRET = "secret" 4 | SESSION_MAX_AGE = 7200000 #1000 * 60 * 60 * 2 5 | MONGO_DB_URI = "mongodb+srv://user:password@hostname/test?retryWrites=true&w=majority" 6 | EMAIL_USER = "example@ethereal.email" 7 | EMAIL_PASSWORD = "exaplepassword" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public 3 | dist -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "babel-eslint", 4 | "parserOptions": { 5 | "ecmaFeatures": { 6 | "jsx": true 7 | }, 8 | "ecmaVersion": 2018, 9 | "sourceType": "module" 10 | }, 11 | "env": { 12 | "browser": true, 13 | "es6": true, 14 | "node": true 15 | }, 16 | "extends": [ 17 | "standard", 18 | "plugin:react/recommended", 19 | "plugin:jsx-a11y/recommended", 20 | "prettier", 21 | "prettier/standard", 22 | "plugin:import/errors", 23 | "plugin:import/warnings" 24 | ], 25 | "globals": { 26 | "Atomics": "readonly", 27 | "SharedArrayBuffer": "readonly" 28 | }, 29 | "settings": { 30 | "react": { 31 | "version": "latest" 32 | }, 33 | "import/resolver": { 34 | "node": { 35 | "paths": ["src"] 36 | } 37 | } 38 | }, 39 | "plugins": ["import", "prettier", "standard", "react", "jsx-a11y"], 40 | "rules": { 41 | "no-console": "off", 42 | "react/jsx-filename-extension": [ 43 | 1, 44 | { 45 | "extensions": [".js", "jsx"] 46 | } 47 | ], 48 | "react/jsx-uses-react": 1, 49 | "react/jsx-uses-vars": 1, 50 | "react/prop-types": 0, 51 | "prettier/prettier": 1 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | dist 4 | *.log 5 | package-lock.json 6 | yarn.lock -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | public -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 8 5 | 6 | script: 7 | - npm run build 8 | 9 | branches: 10 | only: 11 | - gh-pages 12 | - /.*/ 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.DS_Store": true, 4 | "**/.git": true, 5 | "build": true, 6 | "**/node_modules": true, 7 | "node_modules": true 8 | }, 9 | "editor.formatOnSave": true, 10 | "prettier.eslintIntegration": false, 11 | "eslint.validate": ["javascript", "javascriptreact"], 12 | "javascript.validate.enable": false 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Igor Cesar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react boilerplate banner 2 | 3 |
4 | 5 |
6 | 7 | Start your next FullStack project in seconds! 8 | 9 |

MER(A)N is a FullStack opinionated monorepo webapp boilerplate based on the MERN stack with Apollo GraphQL.

10 | 11 | Made with ❤️ by IgorMCesar. 12 | 13 |
14 | 15 |
16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 |
26 | 27 |
28 | 29 | CodeFactor Grade 30 | 31 | 32 | GitHub License 33 | 34 | 35 | PRs welcome 36 | 37 |
38 | 39 |
40 | 41 |
42 | 43 | David Client Dependencies 44 | 45 | 46 | David Server Dependencies 47 | 48 |
49 |
50 | 51 | David Core Dependencies 52 | 53 | 54 | David Client Dev Dependencies 55 | 56 | 57 | David Server Dev Dependencies 58 | 59 |
60 |
61 | 62 | Snyk Core Vulnerabilities 63 | 64 | 65 | Snyk Client Vulnerabilities 66 | 67 | 68 | Snyk Server Vulnerabilities 69 | 70 |
71 | 72 |
73 | 74 | ![Porject Example Gif](https://user-images.githubusercontent.com/5064518/62419349-7badb300-b654-11e9-919b-8e6777aa6ea1.gif) 75 | 76 | ## Tech Stack 77 | 78 | **Core Dev** 79 | 80 | - **[Lerna](https://github.com/lerna/lerna)** — JavaScript monorepo project manager 81 | 82 | - [ESLint](https://eslint.org/), [Prettier](https://prettier.io/) — static code analysis 83 | 84 | - [Husky](https://github.com/typicode/husky), [lint-staged](https://github.com/okonet/lint-staged) — Git hooks to run linters against staged git files 85 | 86 | **Server** 87 | 88 | - [x] **[Node.js](https://nodejs.org)** 89 | - [x] **[Express](https://github.com/expressjs/express)** 90 | - [x] **[GraphQL](http://graphql.org/)** with [Apollo Server Express](https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-express) 91 | - [x] **[MongoDB](https://www.mongodb.com/)** with [Mongoose](https://github.com/Automattic/mongoose) 92 | - [x] [Express Session](https://github.com/expressjs/session) 93 | - [x] [Body Parser](https://github.com/expressjs/body-parser) 94 | - [x] [NodeMailer](https://github.com/nodemailer/nodemailer) 95 | - [x] [Helmet](https://github.com/helmetjs/helmet) 96 | - [x] [Bcrypt.js](https://github.com/dcodeIO/bcrypt.js) 97 | - [x] [Morgan](https://github.com/expressjs/morgan) 98 | - [x] [Joi](https://github.com/hapijs/joi) 99 | - [x] [Chalk](https://github.com/chalk/chalk) 100 | 101 | **Client** 102 | 103 | - [x] **[React](https://reactjs.org/)** 104 | - [x] **[React Router](https://github.com/ReactTraining/react-router)** 105 | - [x] **[Redux](https://redux.js.org/)** 106 | - [x] Redux Thunk 107 | - [x] **[Webpack](https://github.com/webpack/webpack)** 108 | - [x] **[Babel](https://babeljs.io/)** 109 | - [x] **[GraphQL](http://graphql.org/)** with [Apollo Client (Boost)](https://github.com/apollographql/apollo-client/tree/master/packages/apollo-boost) 110 | - [x] [Less](http://lesscss.org/) 111 | - [x] [Ant Design](https://ant.design/) 112 | - [x] [Formik](https://jaredpalmer.com/formik/) 113 | - [x] [Yup](https://github.com/jquense/yup) 114 | - [ ] **[Jest](https://jestjs.io/)** with 100% test coverage 115 | - [ ] **[i18n](https://github.com/mashpie/i18n-node)** 116 | - [ ] **[Normalizr](https://github.com/paularmstrong/normalizr)** 117 | - [ ] **[React 16.8 Hooks](https://reactjs.org/docs/hooks-overview.html)** 118 | 119 | ## Getting Started 120 | 121 | ### Prerequisites 122 | 123 | - [MongoDB](https://www.mongodb.com/download-center/community) or [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) 124 | - [Node.js 10.0+](http://nodejs.org) 125 | - Code Editor — [Visual Studio Code](https://code.visualstudio.com/) (preferred) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) plug-ins. 126 | - Command Line Tools 127 | 128 | **Note:** If you are new to Node, Express, Mongo, React or GraphQL you may find 129 | [Build a Complete App with GraphQL, Node.js, MongoDB and React.js](https://www.youtube.com/watch?v=7giZGFDGnkc&list=PL55RiY5tL51rG1x02Yyj93iypUuHYXcB_&index=1) from [@maxedapps](https://twitter.com/maxedapps) helpful for learning everything you need to know. 130 | 131 | ### Installing 132 | 133 | The easiest way is to clone the repository and run `npm run install:all`: 134 | 135 | ```bash 136 | # Get the latest version 137 | git clone https://github.com/IgorMCesar/react-node-boilerplate.git myproject 138 | 139 | # Change current directory to the newly created one 140 | cd myproject 141 | 142 | # Install NPM dependencies of all packages(core, server and client) 143 | npm run install:all 144 | ``` 145 | 146 | Now lets set the environment variables. Open `.env.example` and use it to create your own `.env` file and set your variables. 147 | 148 | **Note:** If you are using mongo server locally `MONGO_DB_URI` should look something like this `mongodb://localhost:27017/test` 149 | 150 | ## Running React Node Boilerplate 151 | 152 | ### Development 153 | 154 | To run MERN(A) using development configs run: 155 | 156 | ```bash 157 | npm run dev 158 | ``` 159 | 160 | ### Production 161 | 162 | #### // TODO 163 | 164 | ## Deployment 165 | 166 | #### // TODO 167 | 168 | **Note:** Make sure you didn't forget any sensitive information in your code, remeber to always use `.env` file for that! 169 | 170 | ## License 171 | 172 | This project is licensed under the MIT license, Copyright © 2019 Igor Cesar. For more information see [LICENSE](https://github.com/IgorMCesar/react-express-mongo-boilerplate/blob/master/LICENSE). 173 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["src/*"], 3 | "version": "independent" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-node-boilerplate", 3 | "version": "1.0.0", 4 | "description": "React Node Boilerplate is a MERN stack with GraphQL.", 5 | "scripts": { 6 | "dev": "lerna exec -- npm run --parallel dev", 7 | "lint": "eslint '**/*.{js,jsx}'", 8 | "lint:fix": "npm run lint -- --fix", 9 | "test": "lerna run --parallel test", 10 | "install:all": "npm install && lerna bootstrap" 11 | }, 12 | "devDependencies": { 13 | "babel-eslint": "^10.0.1", 14 | "eslint": "^6.0.1", 15 | "eslint-config-prettier": "^6.0.0", 16 | "eslint-config-standard": "^13.0.1", 17 | "eslint-plugin-import": "^2.18.0", 18 | "eslint-plugin-jest": "^22.6.4", 19 | "eslint-plugin-jsx-a11y": "^6.2.1", 20 | "eslint-plugin-node": "^9.1.0", 21 | "eslint-plugin-prettier": "^3.1.0", 22 | "eslint-plugin-promise": "^4.2.1", 23 | "eslint-plugin-react": "^7.14.2", 24 | "eslint-plugin-standard": "^4.0.0", 25 | "husky": "^3.0.0", 26 | "lerna": "^3.15.0", 27 | "lint-staged": "^9.2.0", 28 | "prettier": "^1.18.2" 29 | }, 30 | "husky": { 31 | "hooks": { 32 | "pre-commit": "lint-staged" 33 | } 34 | }, 35 | "lint-staged": { 36 | "*.{js,jsx}": [ 37 | "eslint" 38 | ], 39 | "*.{js,jsx,json,md}": [ 40 | "prettier --list-different" 41 | ], 42 | "*.js": [ 43 | "eslint --fix", 44 | "git add" 45 | ] 46 | }, 47 | "repository": { 48 | "type": "git", 49 | "url": "git+https://github.com/IgorMCesar/react-node-boilerplate.git" 50 | }, 51 | "author": "", 52 | "license": "ISC", 53 | "bugs": { 54 | "url": "https://github.com/IgorMCesar/react-node-boilerplate/issues" 55 | }, 56 | "homepage": "https://github.com/IgorMCesar/react-node-boilerplate#readme", 57 | "dependencies": {} 58 | } 59 | -------------------------------------------------------------------------------- /src/client/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ConnectedRouter } from 'connected-react-router'; 4 | import { Switch, Route } from 'react-router-dom'; 5 | import PrivateRoute from './components/PrivateRoute/PrivateRoute'; 6 | import GuestRoute from './components/GuestRoute/GuestRoute'; 7 | import CheckIfLoggedIn from './components/CheckIfLoggedIn/CheckIfLoggedIn'; 8 | 9 | import LoginPage from './pages/Login/Login'; 10 | import RegisterPage from './pages/Register/Register'; 11 | import DashboardPage from './pages/Dashboard/Dashboard'; 12 | import PageNotFound from './pages/NotFound/NotFound'; 13 | 14 | const App = props => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | App.propTypes = { 30 | history: PropTypes.object 31 | }; 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /src/client/components/CheckIfLoggedIn/CheckIfLoggedIn.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Spin } from 'antd'; 4 | import { useMutation } from '@apollo/react-hooks'; 5 | import { connect } from 'react-redux'; 6 | import { bindActionCreators } from 'redux'; 7 | 8 | import { mutations } from '../../graphql/graphql'; 9 | import actions from '../../store/actions/actions'; 10 | 11 | const CheckIfLoggedIn = props => { 12 | if (props.firstAuthValidationDone) return props.children; 13 | 14 | const [CheckIfLoggedIn, { data, loading, error }] = useMutation(mutations.VERIFY_LOGGED_IN); 15 | 16 | useEffect(() => { 17 | CheckIfLoggedIn(); 18 | }, []); 19 | 20 | if (loading) { 21 | return ; 22 | } 23 | 24 | if (data) { 25 | props.setFirstAuthState(true, data.LogIn); 26 | console.log('Did First Auth Validation'); 27 | } 28 | 29 | if (error) { 30 | props.setFirstAuthState(false, null); 31 | console.log('Did First Auth Validation'); 32 | } 33 | 34 | return props.children; 35 | }; 36 | 37 | const mapStateToProps = state => { 38 | return { 39 | firstAuthValidationDone: state.auth.firstAuthValidationDone 40 | }; 41 | }; 42 | 43 | const mapDispatchToProps = dispatch => { 44 | return bindActionCreators( 45 | { 46 | setFirstAuthState: actions.setFirstAuthState 47 | }, 48 | dispatch 49 | ); 50 | }; 51 | 52 | CheckIfLoggedIn.propTypes = { 53 | firstAuthValidationDone: PropTypes.bool.isRequired, 54 | setFirstAuthState: PropTypes.func.isRequired 55 | }; 56 | 57 | const connectedCheckIfLoggedIn = connect( 58 | mapStateToProps, 59 | mapDispatchToProps 60 | )(CheckIfLoggedIn); 61 | 62 | export default connectedCheckIfLoggedIn; 63 | -------------------------------------------------------------------------------- /src/client/components/EmailSent/EmailSent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Icon, Card } from 'antd'; 5 | 6 | import _s from './EmailSent.less'; 7 | 8 | const EmailSent = props => { 9 | const { email } = props; 10 | 11 | return ( 12 | 13 |

14 | Email Sent! 15 |

16 | Email sent to {email}! Check your email and verify your account. 17 |
18 | ); 19 | }; 20 | 21 | EmailSent.propTypes = { 22 | email: PropTypes.string.isRequired 23 | }; 24 | 25 | export default EmailSent; 26 | -------------------------------------------------------------------------------- /src/client/components/EmailSent/EmailSent.less: -------------------------------------------------------------------------------- 1 | .EmailSentCard { 2 | width: 100%; 3 | max-width: 500px; 4 | } 5 | -------------------------------------------------------------------------------- /src/client/components/FormInputField/FormInputField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form } from 'antd'; 3 | 4 | export const FormInputField = ({ 5 | field, // { name, value, onChange, onBlur } 6 | form: { touched, errors }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. 7 | InputType, 8 | hasFeedback, 9 | hideErrorMessage, 10 | ...props 11 | }) => { 12 | const errorMessage = hideErrorMessage ? false : touched[field.name] && errors[field.name]; 13 | let inputStatus; 14 | 15 | if (errorMessage) { 16 | inputStatus = 'error'; 17 | } else if (touched[field.name] && field.value) { 18 | inputStatus = 'success'; 19 | } 20 | 21 | return ( 22 | 23 | 24 | {props.children} 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/client/components/GuestRoute/GuestRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | import { connect } from 'react-redux'; 5 | 6 | const GuestRoute = ({ component: Component, loggedIn, user, ...rest }) => { 7 | return ( 8 | 11 | loggedIn ? ( 12 | 13 | ) : ( 14 | 15 | ) 16 | } 17 | /> 18 | ); 19 | }; 20 | 21 | const mapStateToProps = state => { 22 | return { 23 | user: state.auth.user, 24 | loggedIn: state.auth.loggedIn 25 | }; 26 | }; 27 | 28 | GuestRoute.propTypes = { 29 | loggedIn: PropTypes.bool.isRequired, 30 | Component: PropTypes.elementType, 31 | user: PropTypes.object 32 | }; 33 | 34 | const ConnectedGuestRoute = connect(mapStateToProps)(GuestRoute); 35 | 36 | export default ConnectedGuestRoute; 37 | -------------------------------------------------------------------------------- /src/client/components/Layouts/GuestLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Layout, Menu, Icon } from 'antd'; 3 | import { Link } from 'react-router-dom'; 4 | import { withRouter } from 'react-router'; 5 | 6 | import _s from './Layouts.less'; 7 | 8 | const { Header, Footer, Content } = Layout; 9 | 10 | const GuestLayout = props => ( 11 | 12 |
13 | 20 | 21 | 22 | menu 23 | 24 | 25 | 26 | 27 | Log In 28 | 29 | 30 | 31 | 32 |
33 | {props.children} 34 |
MER(A)N - FullStack Boilerplate by IgorMCesar
35 |
36 | ); 37 | 38 | export default withRouter(GuestLayout); 39 | -------------------------------------------------------------------------------- /src/client/components/Layouts/Layouts.less: -------------------------------------------------------------------------------- 1 | .Content { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | 7 | .UserAvatar { 8 | :global { 9 | .anticon { 10 | min-width: unset; 11 | margin-right: unset; 12 | font-size: unset; 13 | } 14 | } 15 | } 16 | 17 | .logo { 18 | height: 40px; 19 | margin-top: -5px; 20 | } 21 | 22 | .login { 23 | float: right; 24 | a { 25 | color: #ffffff !important; 26 | } 27 | .loginIcon { 28 | transition: transform 200ms ease 0s; 29 | -ms-transform: translate(-0px, 0px) scale(1); 30 | transform: translate(-0px, 0px) scale(1); 31 | } 32 | &:hover { 33 | .loginIcon { 34 | transition: transform 200ms ease 0s; 35 | -ms-transform: translate(2px, 0px) scale(1.4); 36 | transform: translate(2px, 0px) scale(1.4); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/client/components/Layouts/LoggedLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Layout, Menu, Avatar, Icon, message } from 'antd'; 4 | import { Link } from 'react-router-dom'; 5 | import { useMutation } from '@apollo/react-hooks'; 6 | import { connect } from 'react-redux'; 7 | import { bindActionCreators } from 'redux'; 8 | 9 | import { withRouter } from 'react-router'; 10 | import { mutations } from '../../graphql/graphql'; 11 | import actions from '../../store/actions/actions'; 12 | 13 | import _s from './Layouts.less'; 14 | 15 | const { SubMenu } = Menu; 16 | const { Header, Footer, Content } = Layout; 17 | 18 | const LoggedLayout = props => { 19 | const [LogOut] = useMutation(mutations.LOG_OUT); 20 | 21 | const handleLogOut = e => { 22 | if (e.key === 'LogOut') { 23 | LogOut() 24 | .then(res => { 25 | props.removeAuthUser(); 26 | message.success('Logged out successfully'); 27 | props.history.push('/'); 28 | }) 29 | .catch(err => console.log(err)); 30 | } 31 | }; 32 | 33 | return ( 34 | 35 |
36 | 43 | 44 | 45 | menu 46 | 47 | 48 | 49 | Dashboard 50 | 51 | 54 | 55 | 56 | 57 | } 58 | style={{ float: 'right' }} 59 | > 60 | Profile 61 | handleLogOut(e)} key="LogOut"> 62 | Sign Out 63 | 64 | 65 | 66 |
67 | {props.children} 68 |
MER(A)N - FullStack Boilerplate by IgorMCesar
69 |
70 | ); 71 | }; 72 | 73 | const mapStateToProps = state => { 74 | return { 75 | user: state.auth.user, 76 | loggedIn: state.auth.loggedIn 77 | }; 78 | }; 79 | 80 | const mapDispatchToProps = dispatch => { 81 | return bindActionCreators( 82 | { 83 | removeAuthUser: actions.removeAuthUser 84 | }, 85 | dispatch 86 | ); 87 | }; 88 | 89 | LoggedLayout.propTypes = { 90 | user: PropTypes.object, 91 | loggedIn: PropTypes.bool.isRequired, 92 | removeAuthUser: PropTypes.func.isRequired 93 | }; 94 | 95 | const connectedLoggedLayout = connect( 96 | mapStateToProps, 97 | mapDispatchToProps 98 | )(LoggedLayout); 99 | 100 | export default withRouter(connectedLoggedLayout); 101 | -------------------------------------------------------------------------------- /src/client/components/LoginForm/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useMutation } from '@apollo/react-hooks'; 4 | import { connect } from 'react-redux'; 5 | import { bindActionCreators } from 'redux'; 6 | import { Link } from 'react-router-dom'; 7 | import { Formik, Field } from 'formik'; 8 | import { Form, Icon, Input, Button, Checkbox, Card, Alert, message } from 'antd'; 9 | 10 | import { FormInputField } from '../FormInputField/FormInputField'; 11 | 12 | import validators from '../../validators/validators'; 13 | import { mutations } from '../../graphql/graphql'; 14 | import actions from '../../store/actions/actions'; 15 | 16 | import _s from './LoginForm.less'; 17 | 18 | const LoginForm = props => { 19 | const [LogIn] = useMutation(mutations.LOG_IN); 20 | 21 | const handleSubmitForm = async (values, actions) => { 22 | const { email, password } = values; 23 | const { setErrors, setSubmitting } = actions; 24 | 25 | LogIn({ variables: { email, password } }).then( 26 | res => { 27 | props.setAuthUser(res.data.LogIn); 28 | message.success('Logged in successfully'); 29 | }, 30 | err => { 31 | setSubmitting(false); 32 | err.graphQLErrors.map(x => { 33 | console.log(x.message); 34 | // TODO NOT VERIFIED MESSAGE 35 | // if (x.message.includes('email')) errors.email = 'Email has already been taken.'; 36 | // if (x.message.includes('username')) errors.username = 'Username has already been taken.'; 37 | }); 38 | setErrors({ auth: 'Incorrect email or password.' }); 39 | } 40 | ); 41 | }; 42 | 43 | return ( 44 | 45 |

46 | Log In 47 |

48 | 49 | handleSubmitForm(values, actions)} 55 | render={formikProps => { 56 | const { errors, isSubmitting, handleSubmit } = formikProps; 57 | 58 | return ( 59 | <> 60 | {Object.keys(errors).length > 0 && ( 61 | 67 | )} 68 |
69 | } 73 | name="email" 74 | placeholder="Email" 75 | hideErrorMessage={true} 76 | /> 77 | } 81 | name="password" 82 | placeholder="Password" 83 | type="password" 84 | hideErrorMessage={true} 85 | /> 86 | 87 | Remember me 88 | 89 | Forgot password 90 | 91 | 99 | Or register now! 100 | 101 | 102 | 103 | ); 104 | }} 105 | /> 106 |
107 | ); 108 | }; 109 | 110 | const mapStateToProps = state => { 111 | return { 112 | user: state.auth.user, 113 | loggedIn: state.auth.loggedIn 114 | }; 115 | }; 116 | 117 | const mapDispatchToProps = dispatch => { 118 | return bindActionCreators( 119 | { 120 | setAuthUser: actions.setAuthUser 121 | }, 122 | dispatch 123 | ); 124 | }; 125 | 126 | LoginForm.propTypes = { 127 | loggedIn: PropTypes.bool.isRequired, 128 | setAuthUser: PropTypes.func.isRequired, 129 | user: PropTypes.object 130 | }; 131 | 132 | export default connect( 133 | mapStateToProps, 134 | mapDispatchToProps 135 | )(LoginForm); 136 | -------------------------------------------------------------------------------- /src/client/components/LoginForm/LoginForm.less: -------------------------------------------------------------------------------- 1 | .loginFormForgot { 2 | float: right; 3 | } 4 | .loginFormButton { 5 | width: 100%; 6 | } 7 | 8 | .LoginFormCard { 9 | max-width: 350px; 10 | :global { 11 | .ant-form-item { 12 | margin-bottom: 20px; 13 | } 14 | .ant-input-prefix { 15 | background-color: transparent; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/client/components/PrivateRoute/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | import { connect } from 'react-redux'; 5 | 6 | const PrivateRoute = ({ component: Component, loggedIn, user, ...rest }) => { 7 | return ( 8 | 11 | loggedIn ? ( 12 | 13 | ) : ( 14 | 15 | ) 16 | } 17 | /> 18 | ); 19 | }; 20 | 21 | const mapStateToProps = state => { 22 | return { 23 | user: state.auth.user, 24 | loggedIn: state.auth.loggedIn 25 | }; 26 | }; 27 | 28 | PrivateRoute.propTypes = { 29 | loggedIn: PropTypes.bool.isRequired, 30 | Component: PropTypes.elementType, 31 | user: PropTypes.object 32 | }; 33 | 34 | const ConnectedPrivateRoute = connect(mapStateToProps)(PrivateRoute); 35 | 36 | export default ConnectedPrivateRoute; 37 | -------------------------------------------------------------------------------- /src/client/components/RegisterForm/RegisterForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useMutation } from '@apollo/react-hooks'; 3 | import { Link } from 'react-router-dom'; 4 | import { Formik, Field } from 'formik'; 5 | import { Form, Icon, Input, Button, Checkbox, Card } from 'antd'; 6 | 7 | import { FormInputField } from '../FormInputField/FormInputField'; 8 | 9 | import validators from '../../validators/validators'; 10 | import { mutations } from '../../graphql/graphql'; 11 | 12 | import EmailSent from '../EmailSent/EmailSent'; 13 | import _s from './RegisterForm.less'; 14 | 15 | const RegisterForm = props => { 16 | const [registeredEmail, setRegisteredEmail] = useState(); 17 | 18 | const [SignUp] = useMutation(mutations.SIGN_UP); 19 | 20 | const handleSubmitForm = async (values, actions) => { 21 | const { email, password, name, username } = values; 22 | const { setErrors, setSubmitting } = actions; 23 | 24 | SignUp({ variables: { email, password, name, username } }).then( 25 | res => { 26 | setRegisteredEmail(email); 27 | }, 28 | err => { 29 | const errors = {}; 30 | 31 | err.graphQLErrors.map(x => { 32 | if (x.message.includes('email')) { 33 | errors.email = 'Email has already been taken.'; 34 | } 35 | if (x.message.includes('username')) { 36 | errors.username = 'Username has already been taken.'; 37 | } 38 | }); 39 | setSubmitting(false); 40 | setErrors(errors); 41 | } 42 | ); 43 | }; 44 | 45 | if (registeredEmail) { 46 | return ; 47 | } else { 48 | return ( 49 | 50 |

51 | Register 52 |

53 | handleSubmitForm(values, actions)} 65 | render={formikProps => { 66 | const { isSubmitting, handleSubmit } = formikProps; 67 | 68 | return ( 69 |
70 | } 74 | name="username" 75 | placeholder="Username" 76 | hasFeedback 77 | /> 78 | } 82 | name="name" 83 | placeholder="Name" 84 | hasFeedback 85 | /> 86 | } 90 | name="email" 91 | placeholder="Email" 92 | hasFeedback 93 | /> 94 | } 98 | name="password" 99 | placeholder="Password" 100 | hasFeedback 101 | /> 102 | } 106 | name="confirmPassword" 107 | placeholder="Confirm Password" 108 | hasFeedback 109 | /> 110 | 111 | I agree to FAKE Terms of Service 112 | 113 | 114 | 115 | 123 | 124 | Already have an account? Log In 125 | 126 | 127 | 128 | ); 129 | }} 130 | /> 131 |
132 | ); 133 | } 134 | }; 135 | 136 | export default RegisterForm; 137 | -------------------------------------------------------------------------------- /src/client/components/RegisterForm/RegisterForm.less: -------------------------------------------------------------------------------- 1 | .RegisterFormForgot { 2 | float: right; 3 | } 4 | .RegisterFormButton { 5 | width: 100%; 6 | } 7 | 8 | .RegisterFormCard { 9 | width: 100%; 10 | max-width: 500px; 11 | :global { 12 | .ant-form-item { 13 | margin-bottom: 15px; 14 | } 15 | .ant-form-item-with-help { 16 | margin-bottom: 5px; 17 | } 18 | .ant-input-suffix { 19 | background-color: transparent !important; 20 | } 21 | .ant-input-prefix { 22 | background-color: transparent; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/client/config/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | import { applyMiddleware, compose, createStore } from 'redux'; 3 | import { routerMiddleware } from 'connected-react-router'; 4 | import thunk from 'redux-thunk'; 5 | 6 | import createRootReducer from '../store/reducers/reducers'; 7 | 8 | export const history = createBrowserHistory(); 9 | 10 | export default function configureStore(preloadedState) { 11 | const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 12 | const store = createStore( 13 | createRootReducer(history), 14 | preloadedState, 15 | composeEnhancer(applyMiddleware(routerMiddleware(history)), applyMiddleware(thunk)) 16 | ); 17 | 18 | return store; 19 | } 20 | -------------------------------------------------------------------------------- /src/client/config/createApolloClient.js: -------------------------------------------------------------------------------- 1 | import ApolloClient from 'apollo-boost'; 2 | 3 | const client = new ApolloClient({ 4 | uri: 'http://localhost:8080/graphql', 5 | credentials: 'include', 6 | onError: ({ graphQLErrors, networkError, reponse }) => { 7 | // if (graphQLErrors) Response.errors = null; 8 | if (graphQLErrors) { 9 | graphQLErrors.map(({ message, locations, path }) => { 10 | // console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`); 11 | }); 12 | } 13 | if (networkError) console.log(`[Network error]: ${networkError}`); 14 | } 15 | }); 16 | 17 | export default client; 18 | -------------------------------------------------------------------------------- /src/client/graphql/graphql.js: -------------------------------------------------------------------------------- 1 | import * as mutations from './mutations/mutations'; 2 | // import * as queries from './queries/queries'; 3 | 4 | export { mutations }; 5 | -------------------------------------------------------------------------------- /src/client/graphql/mutations/mutations.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const SIGN_UP = gql` 4 | mutation SignUp($email: String!, $password: String!, $username: String!, $name: String!) { 5 | signUp(email: $email, password: $password, username: $username, name: $name) { 6 | email 7 | } 8 | } 9 | `; 10 | 11 | export const LOG_IN = gql` 12 | mutation LogIn($email: String!, $password: String!) { 13 | LogIn(email: $email, password: $password) { 14 | id 15 | name 16 | email 17 | username 18 | role 19 | } 20 | } 21 | `; 22 | 23 | export const LOG_OUT = gql` 24 | mutation LogOut { 25 | LogOut 26 | } 27 | `; 28 | 29 | export const VERIFY_LOGGED_IN = gql` 30 | mutation CheckIfLoggedIn { 31 | CheckIfLoggedIn { 32 | id 33 | name 34 | email 35 | username 36 | role 37 | } 38 | } 39 | `; 40 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { BrowserRouter as Router } from 'react-router-dom'; 5 | import { ApolloProvider } from '@apollo/react-hooks'; 6 | 7 | import client from './config/createApolloClient'; 8 | import configureStore, { history } from './config/configureStore'; 9 | 10 | import App from './App'; 11 | 12 | const store = configureStore(); 13 | const Root = () => { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | const rootElement = document.getElementById('root'); 26 | ReactDOM.render(, rootElement); 27 | -------------------------------------------------------------------------------- /src/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-node-boilerplate/client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --mode production --progress", 8 | "dev": "webpack-dev-server --mode development" 9 | }, 10 | "keywords": [], 11 | "author": "Igor Marracho Carriço Cesar", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@apollo/react-hooks": "^3.1.1", 15 | "antd": "^3.20.2", 16 | "apollo-boost": "^0.4.3", 17 | "body-parser": "^1.19.0", 18 | "connected-react-router": "^6.5.2", 19 | "formik": "^1.5.8", 20 | "graphql": "^14.4.2", 21 | "graphql-tag": "^2.10.1", 22 | "react": "^16.8.6", 23 | "react-dom": "^16.8.6", 24 | "react-redux": "^7.1.0", 25 | "react-router": "^5.0.1", 26 | "react-router-dom": "^5.0.1", 27 | "redux": "^4.0.4", 28 | "redux-thunk": "^2.3.0", 29 | "yup": "^0.27.0" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.4.5", 33 | "@babel/plugin-proposal-class-properties": "^7.4.4", 34 | "@babel/plugin-proposal-decorators": "^7.4.4", 35 | "@babel/preset-env": "^7.4.5", 36 | "@babel/preset-react": "^7.0.0", 37 | "babel-loader": "^8.0.6", 38 | "babel-plugin-graphql-tag": "^2.4.0", 39 | "babel-plugin-import": "^1.12.0", 40 | "css-loader": "^2.1.1", 41 | "html-webpack-plugin": "^3.2.0", 42 | "less": "^3.9.0", 43 | "less-loader": "^5.0.0", 44 | "style-loader": "^0.23.1", 45 | "url-loader": "^2.0.0", 46 | "webpack": "^4.35.3", 47 | "webpack-cli": "^3.3.5", 48 | "webpack-dev-server": "^3.7.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/client/pages/Dashboard/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card } from 'antd'; 3 | 4 | import LoggedLayout from '../../components/Layouts/LoggedLayout'; 5 | 6 | // import _s from './Dashboard.less'; 7 | 8 | const DashboardPage = () => ( 9 | 10 | Dashboard Here 11 | 12 | ); 13 | 14 | export default DashboardPage; 15 | -------------------------------------------------------------------------------- /src/client/pages/Dashboard/Dashboard.less: -------------------------------------------------------------------------------- 1 | .Content { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/client/pages/Login/Login.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import LoginForm from '../../components/LoginForm/LoginForm'; 4 | import GuestLayout from '../../components/Layouts/GuestLayout'; 5 | // import _s from './Login.less'; 6 | 7 | const LoginPage = () => ( 8 | 9 | 10 | 11 | ); 12 | 13 | export default LoginPage; 14 | -------------------------------------------------------------------------------- /src/client/pages/Login/Login.less: -------------------------------------------------------------------------------- 1 | .Content { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/client/pages/NotFound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Card } from 'antd'; 4 | import { connect } from 'react-redux'; 5 | 6 | import LoggedLayout from '../../components/Layouts/LoggedLayout'; 7 | import GuestLayout from '../../components/Layouts/GuestLayout'; 8 | 9 | // import _s from './Dashboard.less'; 10 | 11 | const NotFound = props => ( 12 | <> 13 | {props.loggedIn ? ( 14 | 15 | 404 - Page Not Found 16 | 17 | ) : ( 18 | 19 | 404 - Page Not Found 20 | 21 | )} 22 | 23 | ); 24 | 25 | const mapStateToProps = state => { 26 | return { 27 | loggedIn: state.auth.loggedIn 28 | }; 29 | }; 30 | 31 | NotFound.propTypes = { 32 | loggedIn: PropTypes.bool.isRequired 33 | }; 34 | 35 | const connectedNotFound = connect(mapStateToProps)(NotFound); 36 | 37 | export default connectedNotFound; 38 | -------------------------------------------------------------------------------- /src/client/pages/NotFound/NotFound.less: -------------------------------------------------------------------------------- 1 | .Content { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/client/pages/Register/Register.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import RegisterForm from '../../components/RegisterForm/RegisterForm'; 4 | import GuestLayout from '../../components/Layouts/GuestLayout'; 5 | // import _s from './Register.less'; 6 | 7 | const RegisterPage = () => ( 8 | 9 | 10 | 11 | ); 12 | 13 | export default RegisterPage; 14 | -------------------------------------------------------------------------------- /src/client/pages/Register/Register.less: -------------------------------------------------------------------------------- 1 | .Content { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/client/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMCesar/react-express-mongo-boilerplate/23f239668d8830268c883155b0e783ed59f8c0bc/src/client/public/favicon.png -------------------------------------------------------------------------------- /src/client/public/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/client/public/icons/toastr-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/client/public/icons/toastr-separator.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/client/public/icons/toastr-success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/client/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMCesar/react-express-mongo-boilerplate/23f239668d8830268c883155b0e783ed59f8c0bc/src/client/public/images/logo.png -------------------------------------------------------------------------------- /src/client/public/images/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Artboard 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | MER(A)N 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | MER(A)N Boilerplate 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /src/client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "MER(A)N Boilerplate", 3 | "name": "MER(A)N Boilerplate", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/client/store/actions/actions.js: -------------------------------------------------------------------------------- 1 | import authActions from './auth/auth'; 2 | 3 | const actions = { 4 | ...authActions 5 | }; 6 | 7 | export default actions; 8 | -------------------------------------------------------------------------------- /src/client/store/actions/auth/auth.js: -------------------------------------------------------------------------------- 1 | import * as setAuthUser from './setAuthUser'; 2 | import * as removeAuthUser from './removeAuthUser'; 3 | import * as setFirstAuthState from './setFirstAuthState'; 4 | 5 | const authActions = { 6 | ...setAuthUser, 7 | ...removeAuthUser, 8 | ...setFirstAuthState 9 | }; 10 | 11 | export default authActions; 12 | -------------------------------------------------------------------------------- /src/client/store/actions/auth/removeAuthUser.js: -------------------------------------------------------------------------------- 1 | export const REMOVE_AUTH_USER = 'REMOVE_AUTH_USER'; 2 | 3 | export const removeAuthUser = () => dispatch => { 4 | dispatch({ 5 | type: REMOVE_AUTH_USER 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/client/store/actions/auth/setAuthUser.js: -------------------------------------------------------------------------------- 1 | export const SET_AUTH_USER = 'SET_AUTH_USER'; 2 | 3 | export const setAuthUser = user => dispatch => { 4 | dispatch({ 5 | type: SET_AUTH_USER, 6 | user 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /src/client/store/actions/auth/setFirstAuthState.js: -------------------------------------------------------------------------------- 1 | export const SET_FIRST_AUTH_STATE = 'SET_FIRST_AUTH_STATE'; 2 | 3 | export const setFirstAuthState = (loggedIn, user) => ({ 4 | type: SET_FIRST_AUTH_STATE, 5 | user, 6 | loggedIn 7 | }); 8 | -------------------------------------------------------------------------------- /src/client/store/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import authActions from '../actions/auth/auth'; 2 | 3 | const initialState = { 4 | user: null, 5 | loggedIn: false, 6 | firstAuthValidationDone: false 7 | }; 8 | 9 | export default (state = initialState, action) => { 10 | switch (action.type) { 11 | case authActions.SET_AUTH_USER: 12 | return { 13 | ...state, 14 | user: action.user, 15 | loggedIn: true 16 | }; 17 | case authActions.REMOVE_AUTH_USER: 18 | return { 19 | ...state, 20 | user: null, 21 | loggedIn: false 22 | }; 23 | case authActions.SET_FIRST_AUTH_STATE: 24 | return { 25 | ...state, 26 | user: action.user, 27 | loggedIn: action.loggedIn, 28 | firstAuthValidationDone: true 29 | }; 30 | default: 31 | return state; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/client/store/reducers/reducers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-named-default */ 2 | import { combineReducers } from 'redux'; 3 | import { connectRouter } from 'connected-react-router'; 4 | 5 | import { default as auth } from './auth'; 6 | 7 | const rootReducer = history => 8 | combineReducers({ 9 | router: connectRouter(history), 10 | auth 11 | }); 12 | 13 | export default rootReducer; 14 | -------------------------------------------------------------------------------- /src/client/validators/user.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const validMessage = `Please provide a valid email and password`; 4 | 5 | export const loginSchema = yup.object().shape({ 6 | email: yup 7 | .string(validMessage) 8 | .min(3) 9 | .max(255, validMessage) 10 | .email(validMessage) 11 | .required('Please enter your email and password.'), 12 | password: yup 13 | .string(validMessage) 14 | .min(8, validMessage) 15 | .max(50, validMessage) 16 | .matches(/^(?=.*[0-9])(?=.*[a-zA-Z])([a-zA-Z0-9]+)$/, validMessage) 17 | .required('Please enter your email and password.') 18 | }); 19 | 20 | const requiredMessage = field => `${field} is required`; 21 | const minMessage = min => `Must have at least ${min} characters`; 22 | const maxMessage = max => `Cannot have more than ${max} characters`; 23 | 24 | export const registerSchema = yup.object().shape({ 25 | email: yup 26 | .string() 27 | .min(3, minMessage(3)) 28 | .max(255, maxMessage(255)) 29 | .email('Must be a valid email') 30 | .required(requiredMessage('E-mail')), 31 | password: yup 32 | .string() 33 | .min(8) 34 | .max(50, maxMessage(50)) 35 | .matches( 36 | /^(?=.*[0-9])(?=.*[a-zA-Z])([a-zA-Z0-9]+)$/, 37 | 'Must have at least one letter and one digit.' 38 | ) 39 | .required(requiredMessage('Password')), 40 | confirmPassword: yup 41 | .string() 42 | .oneOf([yup.ref('password'), null], 'Passwords must match') 43 | .required(requiredMessage('Password confimation')), 44 | username: yup 45 | .string() 46 | .min(4, minMessage(4)) 47 | .max(30, maxMessage(30)) 48 | .matches(/^[a-zA-Z0-9]*$/, 'Must only contain letters and numbers') 49 | .required(requiredMessage('Username')), 50 | name: yup 51 | .string() 52 | .min(4, minMessage(4)) 53 | .max(255, maxMessage(255)) 54 | .required(requiredMessage('Name')), 55 | terms: yup.boolean().oneOf([true], 'Must Accept Terms and Conditions') 56 | }); 57 | -------------------------------------------------------------------------------- /src/client/validators/validators.js: -------------------------------------------------------------------------------- 1 | import * as user from './user'; 2 | 3 | export default { user }; 4 | -------------------------------------------------------------------------------- /src/client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 4 | 5 | const htmlPlugin = new HtmlWebPackPlugin({ 6 | template: './public/index.html' 7 | }); 8 | 9 | const outputDirectory = 'dist'; 10 | 11 | module.exports = { 12 | entry: './index.js', 13 | output: { 14 | path: path.resolve(__dirname, outputDirectory), 15 | filename: 'bundle.js', 16 | publicPath: '/' 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|jsx)$/, 22 | exclude: /node_modules/, 23 | use: { 24 | loader: 'babel-loader', 25 | options: { 26 | presets: ['@babel/preset-react'], 27 | plugins: [ 28 | ['@babel/plugin-proposal-decorators', { legacy: true }], 29 | '@babel/plugin-proposal-class-properties', 30 | 'graphql-tag', 31 | ['import', { libraryName: 'antd', libraryDirectory: 'es', style: 'css' }] 32 | ] 33 | } 34 | } 35 | }, 36 | { 37 | test: /\.css$/, 38 | use: ['style-loader', 'css-loader'], 39 | exclude: /\.module\.css$/ 40 | }, 41 | { 42 | test: /\.png$/, 43 | use: [ 44 | { 45 | loader: 'url-loader', 46 | options: { 47 | mimetype: 'image/png' 48 | } 49 | } 50 | ] 51 | }, 52 | { 53 | test: /\.less$/, 54 | use: [ 55 | { 56 | loader: 'style-loader' 57 | }, 58 | { 59 | loader: 'css-loader', 60 | options: { 61 | sourceMap: true, 62 | modules: true, 63 | localIdentName: '[name]__[local]___[hash:base64:5]' 64 | } 65 | }, 66 | { 67 | loader: 'less-loader' 68 | } 69 | ] 70 | } 71 | ] 72 | }, 73 | resolve: { 74 | extensions: ['*', '.js', '.jsx'] 75 | }, 76 | devServer: { 77 | port: 3000, 78 | open: true, 79 | historyApiFallback: true, 80 | proxy: { 81 | '/api': 'http://localhost:8080' 82 | } 83 | }, 84 | plugins: [new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en/), htmlPlugin] 85 | }; 86 | -------------------------------------------------------------------------------- /src/server/config/loggerConfig.js: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import morgan from 'morgan'; 3 | import chalk from 'chalk'; 4 | 5 | const loggerConfig = app => { 6 | morgan.token('graphql-query', req => { 7 | const { query, variables, operationName } = req.body; 8 | const { origin, cookie } = req.headers; 9 | if (query) { 10 | return [ 11 | '\n\n', 12 | chalk.magenta.bold('-------GraphQL-------\n'), 13 | chalk.blue.bold('Origin:'), 14 | chalk.yellow.bold(origin), 15 | '\n', 16 | chalk.blue.bold('Cookie:'), 17 | chalk.yellow.bold(cookie), 18 | '\n', 19 | chalk.blue.bold('Operation Name:'), 20 | chalk.yellow.bold(operationName), 21 | '\n', 22 | chalk.blue.bold('Query: '), 23 | chalk.green.bold(query), 24 | chalk.blue.bold('Variables:'), 25 | chalk.yellow.bold(JSON.stringify(variables)), 26 | chalk.magenta.bold('\n---------------------') 27 | ].join(' '); 28 | } 29 | }); 30 | app.use(bodyParser.json()); 31 | app.use(morgan(':graphql-query')); 32 | }; 33 | 34 | export default loggerConfig; 35 | -------------------------------------------------------------------------------- /src/server/graphql/directives/auth.js: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'apollo-server-express'; 2 | import { defaultFieldResolver } from 'graphql'; 3 | import { ensureLoggedIn } from '../../helpers/auth'; 4 | 5 | class AuthDirective extends SchemaDirectiveVisitor { 6 | visitFieldDefinition(field) { 7 | const { resolve = defaultFieldResolver } = field; 8 | 9 | field.resolve = function(...args) { 10 | const context = args[2]; 11 | 12 | ensureLoggedIn(context.req); 13 | 14 | return resolve.apply(this, args); 15 | }; 16 | } 17 | } 18 | 19 | export default AuthDirective; 20 | -------------------------------------------------------------------------------- /src/server/graphql/directives/directives.js: -------------------------------------------------------------------------------- 1 | import AuthDirective from './auth'; 2 | import GuestDirective from './guest'; 3 | import RoleDirective from './role'; 4 | 5 | export default { 6 | auth: AuthDirective, 7 | guest: GuestDirective, 8 | hasRole: RoleDirective 9 | }; 10 | -------------------------------------------------------------------------------- /src/server/graphql/directives/guest.js: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'apollo-server-express'; 2 | import { defaultFieldResolver } from 'graphql'; 3 | import { ensureLoggedOut } from '../../helpers/auth'; 4 | 5 | class GuestDirective extends SchemaDirectiveVisitor { 6 | visitFieldDefinition(field) { 7 | const { resolve = defaultFieldResolver } = field; 8 | 9 | field.resolve = function(...args) { 10 | const context = args[2]; 11 | 12 | ensureLoggedOut(context.req); 13 | 14 | return resolve.apply(this, args); 15 | }; 16 | } 17 | } 18 | 19 | export default GuestDirective; 20 | -------------------------------------------------------------------------------- /src/server/graphql/directives/role.js: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'apollo-server-express'; 2 | import { defaultFieldResolver } from 'graphql'; 3 | import { ensureAuthorized } from '../../helpers/auth'; 4 | 5 | class RoleDirective extends SchemaDirectiveVisitor { 6 | visitObject(type) { 7 | this.ensureFieldsWrapped(type); 8 | type._requiredAuthRole = this.args.role; 9 | } 10 | 11 | visitFieldDefinition(field, details) { 12 | this.ensureFieldsWrapped(details.objectType); 13 | field._requiredAuthRole = this.args.role; 14 | } 15 | 16 | ensureFieldsWrapped(objectType) { 17 | // Mark the GraphQLObjectType object to avoid re-wrapping: 18 | if (objectType._authFieldsWrapped) return; 19 | objectType._authFieldsWrapped = true; 20 | 21 | const fields = objectType.getFields(); 22 | 23 | Object.keys(fields).forEach(fieldName => { 24 | const field = fields[fieldName]; 25 | const { resolve = defaultFieldResolver } = field; 26 | field.resolve = async function(...args) { 27 | // Get the required Role from the field first, falling back 28 | // to the objectType if no Role is required by the field: 29 | const requiredRole = field._requiredAuthRole || objectType._requiredAuthRole; 30 | 31 | if (!requiredRole) { 32 | return resolve.apply(this, args); 33 | } 34 | 35 | const context = args[2]; 36 | 37 | ensureAuthorized(context.req, requiredRole); 38 | 39 | return resolve.apply(this, args); 40 | }; 41 | }); 42 | } 43 | } 44 | 45 | export default RoleDirective; 46 | -------------------------------------------------------------------------------- /src/server/graphql/resolvers/resolvers.js: -------------------------------------------------------------------------------- 1 | import user from './user'; 2 | 3 | export default [user]; 4 | -------------------------------------------------------------------------------- /src/server/graphql/resolvers/user.js: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import { UserInputError, ApolloError } from 'apollo-server-express'; 3 | 4 | import { User } from '../../models/models'; 5 | import validators from '../validators/validators'; 6 | import * as Auth from '../../helpers/auth'; 7 | import { verifyToken, sendEmailWithToken } from '../../helpers/token'; 8 | 9 | export default { 10 | Query: { 11 | // TODO: projection, pagination, sanitization 12 | users: (root, args, context, info) => User.find({}), 13 | user: async (root, args, context, info) => { 14 | // TODO: projection 15 | await Joi.validate(args, validators.user.findUser); 16 | 17 | return User.findById(args.id); 18 | } 19 | }, 20 | Mutation: { 21 | signUp: async (root, args, context, info) => { 22 | await Joi.validate(args, validators.user.signUp, { abortEarly: false }); 23 | 24 | const user = await User.create(args); 25 | 26 | await sendEmailWithToken(user, 'signUp'); 27 | 28 | return user; 29 | }, 30 | resendSignUpToken: async (root, args, context, info) => { 31 | await Joi.validate(args, validators.user.sendUserToken, { 32 | abortEarly: false 33 | }); 34 | 35 | const user = await User.findOne({ email: args.email }); 36 | 37 | if (!user) { 38 | throw new UserInputError('Email not found.'); 39 | } else if (user.isVerified) { 40 | throw new ApolloError('User already verified.', 'USER_ALREADY_VERIFIED'); 41 | } 42 | 43 | const token = await sendEmailWithToken(user, 'signUp'); 44 | 45 | return !!token._id; 46 | }, 47 | verifyUser: async (root, args, context, info) => { 48 | await Joi.validate(args, validators.user.verifyUser); 49 | 50 | const verifiedToken = await verifyToken(args.token, 'signUp'); 51 | 52 | const res = await User.updateOne({ _id: verifiedToken.user }, { isVerified: true }); 53 | 54 | return !!res.nModified > 0; 55 | }, 56 | LogIn: async (root, args, context, info) => { 57 | await Joi.validate(args, validators.user.LogIn, { abortEarly: false }); 58 | 59 | const user = await Auth.attemptLogIn(args.email, args.password); 60 | 61 | context.req.session.userId = user.id; 62 | context.req.session.userRole = user.role; 63 | 64 | return user; 65 | }, 66 | CheckIfLoggedIn: async (root, args, context, info) => { 67 | const user = await User.findOne({ _id: context.req.session.userId }); 68 | 69 | return user; 70 | }, 71 | LogOut: async (root, args, context, info) => Auth.LogOut(context.req, context.res), 72 | ChangePassword: async (root, args, context, info) => { 73 | await Joi.validate(args, validators.user.ChangePassword, { 74 | abortEarly: false 75 | }); 76 | 77 | const newPassword = await Auth.verifyPasswordChange( 78 | context.req, 79 | args.password, 80 | args.newPassword 81 | ); 82 | 83 | const res = await User.updateOne( 84 | { _id: context.req.session.userId }, 85 | { password: newPassword } 86 | ); 87 | 88 | return res.nModified > 0; 89 | }, 90 | ChangePasswordWithToken: async (root, args, context, info) => { 91 | await Joi.validate(args, validators.user.ChangePasswordWithToken); 92 | 93 | const verifiedToken = await verifyToken(args.token, 'forgotPassword'); 94 | 95 | const newPassword = await Auth.verifyForgotPasswordChange(verifiedToken, args.newPassword); 96 | 97 | const res = await User.updateOne({ _id: verifiedToken.user }, { password: newPassword }); 98 | 99 | return !!res.nModified > 0; 100 | }, 101 | forgotPassword: async (root, args, context, info) => { 102 | await Joi.validate(args, validators.user.sendUserToken, { 103 | abortEarly: false 104 | }); 105 | 106 | const user = await User.findOne({ email: args.email }); 107 | 108 | if (!user) { 109 | throw new UserInputError('Email not found.'); 110 | } 111 | 112 | const token = await sendEmailWithToken(user, 'forgotPassword'); 113 | 114 | return !!token._id; 115 | } 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /src/server/graphql/schemas/root.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | export default gql` 4 | directive @auth on FIELD_DEFINITION 5 | directive @hasRole(role: Role = [USER, ADMIN]) on OBJECT | FIELD_DEFINITION 6 | directive @guest on FIELD_DEFINITION 7 | 8 | enum Role { 9 | ADMIN 10 | USER 11 | } 12 | 13 | type Query { 14 | _: String 15 | } 16 | 17 | type Mutation { 18 | _: String 19 | } 20 | 21 | type Subscription { 22 | _: String 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /src/server/graphql/schemas/schemas.js: -------------------------------------------------------------------------------- 1 | import user from './user'; 2 | import root from './root'; 3 | 4 | export default [user, root]; 5 | -------------------------------------------------------------------------------- /src/server/graphql/schemas/user.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | export default gql` 4 | type User { 5 | id: ID! 6 | email: String! 7 | username: String! 8 | name: String! 9 | role: String! 10 | isVerified: Boolean 11 | createdAt: String! 12 | updatedAt: String! 13 | } 14 | 15 | extend type Query { 16 | user(id: ID!): User @auth @hasRole(role: ADMIN) 17 | users: [User!]! @auth @hasRole(role: ADMIN) 18 | } 19 | 20 | extend type Mutation { 21 | signUp(email: String!, username: String!, name: String!, password: String!): User @guest 22 | resendSignUpToken(email: String!): Boolean @guest 23 | verifyUser(token: String!): Boolean @guest 24 | LogIn(email: String!, password: String!): User @guest 25 | CheckIfLoggedIn: User @auth 26 | LogOut: Boolean @auth 27 | ChangePassword(password: String!, newPassword: String!): Boolean @auth 28 | ChangePasswordWithToken(newPassword: String!, token: String!): Boolean @guest 29 | forgotPassword(email: String!): Boolean @guest 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /src/server/graphql/validators/user.js: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import JoiObjectId from 'joi-objectid'; 3 | 4 | Joi.objectId = JoiObjectId(Joi); 5 | 6 | const email = Joi.string() 7 | .min(3) 8 | .max(255) 9 | .email() 10 | .required() 11 | .label('Email'); 12 | 13 | const username = Joi.string() 14 | .alphanum() 15 | .min(4) 16 | .max(30) 17 | .required() 18 | .label('Username'); 19 | 20 | const name = Joi.string() 21 | .min(4) 22 | .max(255) 23 | .required() 24 | .label('Name'); 25 | 26 | const password = Joi.string() 27 | .min(8) 28 | .max(50) 29 | .regex(/^(?=.*[0-9])(?=.*[a-zA-Z])([a-zA-Z0-9]+)$/) 30 | .options({ 31 | language: { 32 | string: { 33 | regex: { 34 | base: 'must have at least one letter and one digit.' 35 | } 36 | } 37 | } 38 | }) 39 | .required() 40 | .label('Password'); 41 | 42 | const token = Joi.string() 43 | .token() 44 | .length(32); 45 | 46 | export const findUser = Joi.object().keys({ 47 | id: Joi.objectId() 48 | }); 49 | 50 | export const signUp = Joi.object().keys({ 51 | email, 52 | username, 53 | name, 54 | password 55 | }); 56 | 57 | export const LogIn = Joi.object().keys({ 58 | email, 59 | password 60 | }); 61 | 62 | export const ChangePassword = Joi.object().keys({ 63 | password, 64 | newPassword: password 65 | }); 66 | 67 | export const sendUserToken = Joi.object().keys({ 68 | email 69 | }); 70 | 71 | export const verifyUser = Joi.object().keys({ 72 | token 73 | }); 74 | 75 | export const ChangePasswordWithToken = Joi.object().keys({ 76 | token, 77 | newPassword: password 78 | }); 79 | -------------------------------------------------------------------------------- /src/server/graphql/validators/validators.js: -------------------------------------------------------------------------------- 1 | import * as user from './user'; 2 | 3 | export default { user }; 4 | -------------------------------------------------------------------------------- /src/server/helpers/auth.js: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { hash } from 'bcryptjs'; 3 | import { User } from '../models/models'; 4 | 5 | const loggedIn = req => req.session.userId; 6 | const Authorized = req => req.session.userRole; 7 | 8 | export const attemptLogIn = async (email, password) => { 9 | let message = 'Incorrect email or password. Please try again.'; 10 | 11 | const user = await User.findOne({ email }); 12 | 13 | if (!user || !(await user.matchesPassword(password))) { 14 | throw new AuthenticationError(message); 15 | } else if (!user.isVerified) { 16 | message = 'User not verified, check your email!'; 17 | throw new AuthenticationError(message); 18 | } 19 | 20 | return user; 21 | }; 22 | 23 | export const LogOut = (req, res) => 24 | new Promise((resolve, reject) => { 25 | req.session.destroy(err => { 26 | if (err) reject(err); 27 | 28 | res.clearCookie('sid'); 29 | 30 | resolve(true); 31 | }); 32 | }); 33 | 34 | export const verifyPasswordChange = async (req, password, newPassword) => { 35 | let message = 'Same password used. Please choose a new one.'; 36 | 37 | const user = await User.findOne({ _id: loggedIn(req) }); 38 | 39 | if (password === newPassword) { 40 | throw new AuthenticationError(message); 41 | } 42 | 43 | if (!user || !(await user.matchesPassword(password))) { 44 | message = 'Incorrect password. Please try again.'; 45 | throw new AuthenticationError(message); 46 | } 47 | 48 | newPassword = await hash(newPassword, 12); 49 | 50 | return newPassword; 51 | }; 52 | 53 | export const verifyForgotPasswordChange = async (verifiedToken, newPassword) => { 54 | const message = 'Same password used. Please choose a new one.'; 55 | 56 | const user = await User.findOne({ _id: verifiedToken.user }); 57 | 58 | console.log(user); 59 | 60 | if (!user || (await user.matchesPassword(newPassword))) { 61 | throw new AuthenticationError(message); 62 | } 63 | 64 | newPassword = await hash(newPassword, 12); 65 | 66 | return newPassword; 67 | }; 68 | 69 | export const ensureLoggedIn = req => { 70 | if (!loggedIn(req)) { 71 | throw new AuthenticationError('You must be logged in.'); 72 | } 73 | }; 74 | 75 | export const ensureLoggedOut = req => { 76 | if (loggedIn(req)) { 77 | throw new AuthenticationError('You are already logged in.'); 78 | } 79 | }; 80 | 81 | export const ensureAuthorized = (req, requiredRole) => { 82 | if (Authorized(req) !== requiredRole) { 83 | throw new AuthenticationError('You are not Authorized.'); 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/server/helpers/token.js: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-express'; 2 | import crypto from 'crypto'; 3 | 4 | import { Token } from '../models/models'; 5 | import sendEmail from '../utils/sendEmail'; 6 | 7 | export const sendEmailWithToken = async (user, action) => { 8 | await Token.deleteOne({ user: user._id, action }); 9 | 10 | const token = await Token.create({ 11 | user: user._id, 12 | token: crypto.randomBytes(16).toString('hex'), 13 | action 14 | }); 15 | 16 | // No await! If the email is not sent the user simply needs to ask for a new one! 17 | sendEmail('example@gmail.com', 'http://google.com'); 18 | 19 | return token; 20 | }; 21 | export const verifyToken = async (token, action) => { 22 | const message = 'We were unable to find a valid token. Your token may have expired.'; 23 | 24 | const verifiedToken = await Token.findOne({ token, action }); 25 | 26 | if (!verifiedToken) { 27 | throw new ApolloError(message, 'INVALID_TOKEN'); 28 | } 29 | 30 | return verifiedToken; 31 | }; 32 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import helmet from 'helmet'; 3 | import mongoose from 'mongoose'; 4 | import session from 'express-session'; 5 | import connectMongo from 'connect-mongo'; 6 | import { ApolloServer } from 'apollo-server-express'; 7 | 8 | import loggerConfig from './config/loggerConfig'; 9 | 10 | import typeDefs from './graphql/schemas/schemas'; 11 | import resolvers from './graphql/resolvers/resolvers'; 12 | import schemaDirectives from './graphql/directives/directives'; 13 | 14 | const { NODE_ENV, SESSION_NAME, SESSION_SECRET, SESSION_MAX_AGE, MONGO_DB_URI, PORT } = process.env; 15 | 16 | const app = express(); 17 | 18 | mongoose.set('useCreateIndex', true); 19 | 20 | // Set Secure Headers with Helmet 21 | app.use(helmet()); 22 | app.use(helmet.permittedCrossDomainPolicies()); 23 | 24 | // Serve React Application 25 | // if (NODE_ENV !== 'development') { 26 | app.use(express.static('dist')); 27 | // } 28 | 29 | // Set User Session 30 | const MongoStore = connectMongo(session); 31 | app.use( 32 | session({ 33 | store: new MongoStore({ mongooseConnection: mongoose.connection }), 34 | name: SESSION_NAME, 35 | secret: SESSION_SECRET, 36 | resave: true, 37 | rolling: true, 38 | saveUninitialized: false, 39 | cookie: { 40 | maxAge: parseInt(SESSION_MAX_AGE, 10), 41 | sameSite: true, 42 | httpOnly: true, 43 | secure: !NODE_ENV.trim() === 'development' 44 | } 45 | }) 46 | ); 47 | 48 | const server = new ApolloServer({ 49 | typeDefs, 50 | resolvers, 51 | schemaDirectives, 52 | playground: 53 | NODE_ENV.trim() !== 'development' 54 | ? false 55 | : { 56 | settings: { 57 | 'request.credentials': 'include', 58 | 'schema.polling.enable': false 59 | } 60 | }, 61 | context: ({ req, res }) => ({ req, res }) 62 | }); 63 | 64 | // Logging with Morgan 65 | if (NODE_ENV === 'development') { 66 | loggerConfig(app); 67 | } 68 | 69 | server.applyMiddleware({ 70 | app, 71 | cors: { 72 | credentials: true, 73 | origin: 'http://localhost:3000' 74 | } 75 | }); 76 | 77 | mongoose.connect(MONGO_DB_URI, { useNewUrlParser: true }); 78 | mongoose.connection.once('open', () => { 79 | const port = PORT || 8080; 80 | app.listen({ port }, () => { 81 | console.log(`Server running on port ${port}`); 82 | }); 83 | }); 84 | mongoose.connection.on('error', error => console.error(error)); 85 | -------------------------------------------------------------------------------- /src/server/models/models.js: -------------------------------------------------------------------------------- 1 | import User from './user'; 2 | import Token from './token'; 3 | 4 | export { User, Token }; 5 | -------------------------------------------------------------------------------- /src/server/models/token.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { Schema, model } = mongoose; 4 | const { ObjectId } = Schema.Types; 5 | 6 | const tokenSchema = new Schema( 7 | { 8 | token: String, 9 | action: String, 10 | user: { 11 | type: ObjectId, 12 | ref: 'User' 13 | }, 14 | expires: { 15 | type: Date, 16 | default: new Date(Date.now() + 12 * 60 * 60 * 1000), 17 | index: { 18 | expireAfterSeconds: 12 * 60 * 60 19 | } 20 | } 21 | }, 22 | { 23 | timestamps: true 24 | } 25 | ); 26 | 27 | const Token = model('Token', tokenSchema); 28 | 29 | export default Token; 30 | -------------------------------------------------------------------------------- /src/server/models/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { hash, compare } from 'bcryptjs'; 3 | 4 | const { Schema, model } = mongoose; 5 | 6 | const userSchema = new Schema( 7 | { 8 | email: { 9 | type: String, 10 | validate: { 11 | validator: email => User.doesntExist({ email }), 12 | message: () => 'Email has already been taken.' 13 | } 14 | }, 15 | username: { 16 | type: String, 17 | validate: { 18 | validator: username => User.doesntExist({ username }), 19 | message: () => 'Username has already been taken.' 20 | } 21 | }, 22 | name: String, 23 | password: String, 24 | isVerified: { 25 | type: Boolean, 26 | default: false 27 | }, 28 | role: { 29 | type: String, 30 | default: 'USER' 31 | } 32 | }, 33 | { 34 | timestamps: true 35 | } 36 | ); 37 | 38 | userSchema.pre('save', async function() { 39 | if (this.isModified('password')) { 40 | this.password = await hash(this.password, 12); 41 | } 42 | }); 43 | 44 | userSchema.statics.doesntExist = async function(options) { 45 | return (await this.where(options).countDocuments()) === 0; 46 | }; 47 | 48 | userSchema.methods.matchesPassword = function(password) { 49 | return compare(password, this.password); 50 | }; 51 | 52 | const User = model('User', userSchema); 53 | 54 | export default User; 55 | -------------------------------------------------------------------------------- /src/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-node-boilerplate/server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "./index.js", 6 | "scripts": { 7 | "check-staged": "lint-staged", 8 | "start": "node -r esm index.js", 9 | "dev": "nodemon -r esm -r dotenv/config index.js dotenv_config_path=../../.env" 10 | }, 11 | "keywords": [], 12 | "author": "Igor Marracho Carriço Cesar", 13 | "license": "ISC", 14 | "dependencies": { 15 | "apollo-server-express": "^2.6.3", 16 | "bcryptjs": "^2.4.3", 17 | "body-parser": "^1.19.0", 18 | "chalk": "^2.4.2", 19 | "connect-mongo": "^3.0.0", 20 | "esm": "^3.2.25", 21 | "express": "^4.17.1", 22 | "express-session": "^1.16.2", 23 | "graphql": "^14.4.2", 24 | "graphql-tag": "^2.10.1", 25 | "helmet": "^3.18.0", 26 | "joi": "^14.3.1", 27 | "joi-objectid": "^2.0.0", 28 | "lodash": "^4.17.11", 29 | "moment": "^2.24.0", 30 | "mongoose": "^5.6.0", 31 | "morgan": "^1.9.1", 32 | "nodemailer": "^6.2.1" 33 | }, 34 | "devDependencies": { 35 | "dotenv": "^8.0.0", 36 | "nodemon": "^1.19.1", 37 | "prettier": "^1.18.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/server/utils/sendEmail.js: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | 3 | const sendEmail = async (recipient, url) => { 4 | const transporter = nodemailer.createTransport({ 5 | host: 'smtp.ethereal.email', 6 | port: 587, 7 | secure: false, // true for 465, false for other ports 8 | auth: { 9 | user: process.env.EMAIL_USER, // generated ethereal user 10 | pass: process.env.EMAIL_PASSWORD // generated ethereal password 11 | } 12 | }); 13 | 14 | // send mail with defined transport object 15 | const info = await transporter.sendMail({ 16 | from: '"Fred Foo 👻" ', // sender address 17 | to: `${recipient}`, // list of receivers 18 | subject: 'Hello ✔', // Subject line 19 | text: 'Hello world?', // plain text body 20 | html: ` 21 | 22 | Hello world? 23 | Click to Test! 24 | 25 | ` // html body 26 | }); 27 | 28 | console.log('Message sent: %s', info.messageId); 29 | // Message sent: 30 | 31 | // Preview only available when sending through an Ethereal account 32 | console.log('Preview URL: %s', nodemailer.getTestMessageUrl(info)); 33 | // Preview URL: https://ethereal.email/message/WaQKMgKddxQDoou... 34 | }; 35 | 36 | export default sendEmail; 37 | --------------------------------------------------------------------------------