├── .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 |
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 |
38 |
39 |
40 |
41 |
49 |
60 |
71 |
72 |
73 |
74 | 
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 |
32 |
33 | {props.children}
34 |
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 |
66 |
67 | {props.children}
68 |
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 |
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 |
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 |
9 |
--------------------------------------------------------------------------------
/src/client/public/icons/toastr-error.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/src/client/public/icons/toastr-separator.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/client/public/icons/toastr-success.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------