├── .gitignore ├── Dockerfile ├── Jenkinsfile ├── README.md ├── build.sh ├── client ├── .env.example ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── assets │ │ ├── icons │ │ │ ├── flags │ │ │ │ ├── de.svg │ │ │ │ ├── en.svg │ │ │ │ ├── es.svg │ │ │ │ └── fr.svg │ │ │ └── logo.svg │ │ └── images │ │ │ ├── avatar.png │ │ │ ├── bg-app.jpg │ │ │ └── bg-profile │ │ │ ├── 1.jpg │ │ │ └── 3.jpg │ ├── components │ │ ├── AppError │ │ │ ├── app-error.scss │ │ │ └── index.tsx │ │ ├── AppFeature │ │ │ └── index.tsx │ │ ├── AppSpinner │ │ │ ├── app-spinner.scss │ │ │ └── index.tsx │ │ ├── AuthError │ │ │ ├── auth-error.scss │ │ │ └── index.tsx │ │ ├── CountryInfo │ │ │ ├── CountryColumnItem │ │ │ │ └── index.tsx │ │ │ ├── CountryColumnList │ │ │ │ └── index.tsx │ │ │ ├── country-info.scss │ │ │ └── index.tsx │ │ ├── CustomInputPassword │ │ │ └── index.tsx │ │ ├── ErrorHandler │ │ │ └── index.tsx │ │ ├── Forms │ │ │ ├── ForgotPassword │ │ │ │ ├── form-forgot-password.scss │ │ │ │ └── index.tsx │ │ │ ├── Login │ │ │ │ ├── form-login.scss │ │ │ │ └── index.tsx │ │ │ ├── Register │ │ │ │ ├── form-register.scss │ │ │ │ └── index.tsx │ │ │ └── ResetPassword │ │ │ │ ├── form-reset-password.scss │ │ │ │ └── index.tsx │ │ ├── Loader │ │ │ └── index.tsx │ │ └── LocaleSelector │ │ │ └── index.tsx │ ├── containers │ │ ├── App │ │ │ ├── Dashboard │ │ │ │ ├── dashboard.scss │ │ │ │ └── index.tsx │ │ │ └── Profile │ │ │ │ ├── index.tsx │ │ │ │ └── profile.scss │ │ ├── Auth │ │ │ ├── ConfirmAccount │ │ │ │ ├── confirm-account.scss │ │ │ │ └── index.tsx │ │ │ ├── ForgotPassword │ │ │ │ ├── forgot-password.scss │ │ │ │ └── index.tsx │ │ │ ├── Login │ │ │ │ ├── index.tsx │ │ │ │ └── login.scss │ │ │ ├── Register │ │ │ │ ├── index.tsx │ │ │ │ └── register.scss │ │ │ └── ResetPassword │ │ │ │ ├── index.tsx │ │ │ │ └── reset-password.scss │ │ ├── Layout │ │ │ ├── Footer │ │ │ │ ├── footer.scss │ │ │ │ └── index.tsx │ │ │ ├── Header │ │ │ │ ├── header.scss │ │ │ │ └── index.tsx │ │ │ ├── SideBar │ │ │ │ ├── index.tsx │ │ │ │ └── side-bar.scss │ │ │ ├── index.tsx │ │ │ └── layout.scss │ │ ├── LocaleProvider │ │ │ ├── index.tsx │ │ │ └── locale-context.tsx │ │ ├── NotFound │ │ │ ├── index.tsx │ │ │ └── not-found.scss │ │ └── index.tsx │ ├── hofs │ │ ├── hofs.scss │ │ ├── withApp.tsx │ │ └── withAuth.tsx │ ├── index.scss │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── routes.tsx │ ├── setupTests.ts │ ├── store │ │ ├── app │ │ │ ├── actionTypes.ts │ │ │ ├── actions.ts │ │ │ └── reducer.ts │ │ ├── auth │ │ │ ├── actionTypes.ts │ │ │ ├── actions.ts │ │ │ └── reducer.ts │ │ ├── index.ts │ │ └── socket │ │ │ ├── actionTypes.ts │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ ├── socketIOEmitter.ts │ │ │ └── socketIOListener.ts │ ├── translations │ │ ├── localeTranslator.ts │ │ ├── locales │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ ├── es.json │ │ │ └── fr.json │ │ └── messages.ts │ ├── types │ │ ├── common.ts │ │ ├── form.ts │ │ ├── function.ts │ │ ├── model.ts │ │ └── redux.ts │ └── utils │ │ ├── constants.ts │ │ ├── helpers.ts │ │ ├── http-client.ts │ │ ├── http-reponse-parser.ts │ │ └── local-storage-manager.ts └── tsconfig.json ├── docker-compose.yml ├── run.sh └── server ├── .env.example ├── .gitignore ├── README.md ├── app ├── RestBoilerplate.postman_collection.json ├── controllers │ ├── auth.controller.ts │ ├── task.controller.ts │ └── user.controller.ts ├── core │ ├── config │ │ └── index.ts │ ├── db │ │ └── connect.ts │ ├── locale │ │ └── index.ts │ ├── logger │ │ └── index.ts │ ├── mailer │ │ ├── index.ts │ │ └── templates │ │ │ ├── en │ │ │ ├── confirm-account-email.html │ │ │ ├── forgot-password-email.html │ │ │ └── reset-password-email.html │ │ │ └── fr │ │ │ ├── confirm-account-email.html │ │ │ ├── forgot-password-email.html │ │ │ └── reset-password-email.html │ ├── middleware │ │ ├── auth.ts │ │ └── locale.ts │ ├── storage │ │ └── redis-manager.ts │ └── types │ │ ├── index.ts │ │ ├── models.ts │ │ └── socket.ts ├── index.ts ├── locale │ ├── en │ │ ├── message.json │ │ └── validation.json │ └── fr │ │ ├── message.json │ │ └── validation.json ├── models │ ├── task.model.ts │ └── user.model.ts ├── routes │ ├── auth.route.ts │ ├── default.route.ts │ ├── index.ts │ ├── task.route.ts │ └── user.route.ts ├── socket │ ├── events.ts │ ├── index.ts │ └── tasks │ │ ├── get-country.task.ts │ │ └── task.ts ├── transformers │ ├── task │ │ └── index.ts │ ├── transformer.ts │ └── user │ │ └── index.ts ├── utils │ ├── constants.ts │ ├── helpers.ts │ └── upload-handler.ts └── validator │ ├── index.ts │ ├── task.validator.ts │ └── user.validator.ts ├── package.json ├── public └── apidoc │ ├── api.raml │ ├── examples │ ├── auth │ │ ├── confirm-account │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── forgot-password │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── login │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── register │ │ │ ├── request.json │ │ │ └── response.json │ │ └── reset-password │ │ │ ├── request.json │ │ │ └── response.json │ ├── tasks │ │ ├── create │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── delete │ │ │ └── response.json │ │ ├── get-all │ │ │ └── response.json │ │ └── update │ │ │ └── request.json │ └── users │ │ ├── delete │ │ └── response.json │ │ ├── get-all │ │ └── response.json │ │ ├── get-one │ │ └── response.json │ │ ├── update-password │ │ ├── request.json │ │ └── response.json │ │ └── update │ │ ├── request.json │ │ └── response.json │ ├── index.html │ ├── schemas │ ├── auth │ │ ├── confirm-account │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── forgot-password │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── login │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── register │ │ │ ├── request.json │ │ │ └── response.json │ │ └── reset-password │ │ │ ├── request.json │ │ │ └── response.json │ ├── error-422.json │ ├── error.json │ ├── errors │ │ ├── 400.yml │ │ ├── 403.yml │ │ ├── 404.yml │ │ ├── 422.yml │ │ └── 500.yml │ ├── tasks │ │ ├── create │ │ │ ├── request.json │ │ │ └── response.json │ │ ├── delete │ │ │ └── response.json │ │ ├── get-all │ │ │ └── response.json │ │ └── update │ │ │ └── request.json │ └── users │ │ ├── delete │ │ └── response.json │ │ ├── get-all │ │ └── response.json │ │ ├── get-one-responses-200.json │ │ ├── get-one │ │ └── response.json │ │ ├── update-password │ │ ├── request.json │ │ └── response.json │ │ └── update │ │ ├── request.json │ │ └── response.json │ ├── security │ └── jwt.yml │ └── traits │ └── paged.yml ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | .env.prod 60 | .env.test 61 | 62 | # next.js build output 63 | .next 64 | 65 | .idea 66 | .vscode 67 | log 68 | uploads 69 | build 70 | yarn.lock 71 | package-lock.json 72 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This file is written assuming we are in a folder containing two subfolders 2 | # `build`: Server file ready for production 3 | # `client`: Client file ready for production 4 | FROM mhart/alpine-node:10.16.3 5 | 6 | # Node Alpine doesn't contains bash (/bin/bash) 7 | RUN apk update && apk add bash && rm -rf /var/cache/apk/* 8 | 9 | ARG NODE_ENV=production 10 | ENV NODE_ENV=$NODE_ENV 11 | 12 | RUN mkdir -p /home/www && chmod -R 777 /home/www 13 | 14 | WORKDIR /home/www 15 | 16 | COPY . . 17 | 18 | RUN chmod +x wait-for-it.sh && npm install --no-cache --frozen-lockfile --production 19 | 20 | EXPOSE 7430 21 | 22 | CMD ["./wait-for-it.sh", "mongodb:27017", "-t", "15", "--", "node", "build/index.js"] 23 | 24 | # docker build -t mern-starter:prod . 25 | # docker run -v ${PWD}/db:/data -p 27000:27017 --name mongomern --env-file ./mongo.env rm mongo 26 | # docker run -v ${PWD}:/home/www/public -p 7430:7430 --rm --env-file ./server.env --link mongomern:mongodb mern-starter:prod 27 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | // Config email recipients 2 | def to = emailextrecipients([ 3 | [$class: 'CulpritsRecipientProvider'], // Sent to the manager 4 | [$class: 'DevelopersRecipientProvider'], // The developer wo did th commit 5 | [$class: 'RequesterRecipientProvider'] // If the build is executed manually, notify the person who started it 6 | ]) 7 | 8 | pipeline { 9 | agent any 10 | stages { 11 | stage('Build') { 12 | agent { 13 | docker { 14 | image 'mhart/alpine-node:10.16.3' 15 | args '-v ${ENV_FOLDER}:/home' 16 | } 17 | } 18 | steps { 19 | sh "cp /home/client.env ./client/.env" 20 | sh "./build.sh" 21 | } 22 | } 23 | stage('Deploy') { 24 | steps { 25 | sh "./run.sh" 26 | } 27 | } 28 | } 29 | post { 30 | /*always { 31 | deleteDir() 32 | }*/ 33 | success { 34 | script { 35 | echo "The build completed successfully!" 36 | 37 | // Mark build as failed 38 | // currentBuild.result = "FAILURE"; 39 | 40 | /*def subject = "${env.JOB_NAME} - Build #${env.BUILD_NUMBER} FAILURE" 41 | def content = '${JELLY_SCRIPT,template="html"}' 42 | 43 | // Send email 44 | if(to != null && !to.isEmpty()) { 45 | emailext( 46 | body: content, 47 | mimeType: 'text/html', 48 | replyTo: '$DEFAULT_REPLYTO', 49 | subject: subject, 50 | to: to, 51 | attachLog: true 52 | ) 53 | }*/ 54 | } 55 | } 56 | 57 | failure { 58 | steps { 59 | script { 60 | // Mark build as failed 61 | // currentBuild.result = "FAILURE"; 62 | 63 | echo "Fail to complete all the stages" 64 | 65 | // Mark current build as a failure and throw the error 66 | // throw e; 67 | } 68 | } 69 | } 70 | } 71 | // The options directive is for configuration that applies to the whole job. 72 | options { 73 | // For example, we'd like to make sure we only keep 10 builds at a time, so 74 | // we don't fill up our storage! 75 | // buildDiscarder(logRotator(numToKeepStr:'10')) 76 | 77 | // And we'd really like to be sure that this build doesn't hang forever, so 78 | // let's time it out after an hour. 79 | timeout(time: 60, unit: 'MINUTES') 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Node Starter 2 | This is a starter project i use to quickly start my MERN application with some basic features 3 | 4 | Demo video: [YouTube link](https://www.youtube.com/watch?v=w4-IBA2bRLo&feature=youtu.be) 5 | 6 | ### Folders 7 | The project contains to main folders: 8 | 9 | - **client:** It's the frontend application built with React and Typescript 10 | running on port 3000. it's based on the starter generated by [create-react-app](https://npmjs.com) 11 | 12 | - **server:** 13 | It's the backend application built with Node.js, Express, MongoDB and Typescript 14 | running on port 7430 15 | 16 | ### Features 17 | - **SERVER:** 18 | - **Registration**: Account creation with sent of email confirmation 19 | 20 | - **Authentication**: User authentication with JWT 21 | - **Password reset**: Two endpoint to reset user password 22 | - **Logging**: Log event which when application is running 23 | - **Internationalization**: The client can define the language he want the API to respond with 24 | only English and French are supported 25 | - **Authentication Middleware**: Check if an user is authenticated before access to the resource 26 | - **Refresh token**: Refresh the user's access token before it's expire 27 | - **Socket integration**: Socket is configured to communicate with any client who 28 | connect to him. 29 | - **Request validator**: Validation of the request's body 30 | - **Response transformer**: You can customize the data coming from the database before send it to the client 31 | 32 | - **CLIENT:** 33 | - **Redux integration**: Redux is used for global state management. Some middlewares has been to make his use 34 | straightforward like [promise middleware](https://www.npmjs.com/package/redux-promise-middleware) to make add promise capablity on redux action, 35 | [dynamic middleware](https://www.npmjs.com/package/redux-dynamic-middlewares) to automatically create three redux action's type 36 | 37 | - **Routing**: Use of React router to configure the routing for public, protected and nested routes 38 | - **Lazy loading**: Main component are loaded dynamically and combined with React Suspense to define a loader 39 | when the component is loading 40 | - **Internatinalization**: Four languages is available by default: English, French, German and Spanish. React 41 | Context API is used to manage the languages. I find it more better than Redux 42 | - **Socket integration as Redux middleware**: A custom redux middleware is created for socket. With that, Redux can 43 | handle socket request and response to update state in the reducer. 44 | - **Two main High Order Component**: They have the same features but act at differents part. One enhance components 45 | public part (Register, Login, Password Reset, etc.) and the second enhance component of the protected part. 46 | Once we wrapped our component with, the component can access to ***Router history***, ***Locale provider***, ***Redux actions*** and ***Redux states*** 47 | - **Translation utility**: With that, you can write the text in one language (default) and translate them to the others languages. it uses the 48 | [Google translate API](https://cloud.google.com/translate/docs/) at the bottom so an API key will be required
49 | You need to set an environment variable for the current shell session named GOOGLE_APPLICATION_CREDENTIALS with the value of the path of your Google API credentials 50 | 51 | ### Demo 52 | URL: [https://react-starter.tericcabrel.com](https://react-starter.tericcabrel.com) 53 |
54 | Email address: demo@demo.com
55 | Password: 123456 56 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SERVER_BUILD='./server/build' 4 | CLIENT_BUILD_IN_SERVER='./server/client' 5 | CLIENT_BUILD='./client/build' 6 | 7 | if [ -d ${SERVER_BUILD} ]; then 8 | rm -rf ${SERVER_BUILD} 9 | fi 10 | if [ -d ${CLIENT_BUILD} ]; then 11 | rm -rf ${CLIENT_BUILD} 12 | fi 13 | if [ -d ${CLIENT_BUILD_IN_SERVER} ]; then 14 | rm -rf ${CLIENT_BUILD_IN_SERVER} 15 | fi 16 | 17 | cd ./server 18 | 19 | # Install dependencies 20 | yarn 21 | 22 | # Compile the file from Typescript to ES5 23 | ./node_modules/.bin/tsc 24 | 25 | # Copy the folders who was not moved during the Typescript compilation 26 | cp -rf ./app/locale ./build 27 | mkdir ./build/core/mailer 28 | cp -rf ./app/core/mailer/templates ./build/core/mailer 29 | 30 | cd ../client 31 | 32 | yarn 33 | 34 | yarn build 35 | 36 | cp -rf ./build ../server/client 37 | 38 | cd ../server/ 39 | 40 | cp .env.prod build/.env.prod 41 | 42 | # NODE_ENV=production pm2 start build/index.js --name=react-node 43 | 44 | # For docker 45 | # cp -r ./package.json /home 46 | # cp -rf ./build /home 47 | # cp -rf ./client /home 48 | -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_NAME='Node React Starter' 2 | REACT_APP_SOCKET_URL=http://localhost:7430 3 | REACT_APP_SOCKET_PATH= 4 | REACT_APP_GCP_API_KEY= 5 | REACT_APP_API_BASE_URL=http://localhost:7430/v1 6 | REACT_APP_REST_COUNTRY_BASE_URL=https://restcountries.eu/rest/v2 7 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .env.prod 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | translation.manifest.json 27 | key 28 | add_key.sh 29 | localeTranslator.js 30 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React Node Starter 2 | This is the client part built with React and Typescript 3 | 4 | ## Installation 5 | - Install dependencies 6 | ```bash 7 | $ cd client 8 | $ yarn 9 | ``` 10 | - Create the configuration file and update with your local config 11 | ```bash 12 | $ cp .env.example .env 13 | $ nano .env 14 | ``` 15 | - Start Application 16 | ```bash 17 | $ yarn start 18 | ``` 19 | 20 | ## Translation 21 | You can write text in one language and translate to others languages. A command defined for that 22 | But it use Google translation API so an API key required. You can get it 23 | [here](https://cloud.google.com/translate/docs/quickstart-client-libraries-v3) 24 | Once you have that, update the .env file by setting your 25 | project ID to the property **REACT_APP_GCP_API_KEY** 26 | ```bash 27 | REACT_APP_GCP_API_KEY='my project id' 28 | ``` 29 | A JSON file containing your Private key was given to you. Place it somewhere in your client folder 30 | (public/key for example. It's what i use and public/key is exclude from versioning). 31 | When it's done, run this command in the terminal 32 | 33 | ```bash 34 | export GOOGLE_APPLICATION_CREDENTIALS="/home/user/Downloads/service-account-file.json" 35 | ``` 36 | Replace [PATH] with the file path of the JSON file that contains your service account key. 37 | 38 | Finally run this command to translate the texts 39 | ```bash 40 | yarn trans 41 | ``` 42 | This command will translate only the texts who was not translated yet. To 43 | retranslate all texts, add the flag ***all*** 44 | ```bash 45 | yarn trans --all 46 | ``` 47 | Sometimes, you will want to translate a specific keys (You changed the text). It's 48 | possible by doing this 49 | ```bash 50 | yarn trans --keys=app.home.title 51 | ``` 52 | If you have many keys: 53 | ```bash 54 | yarn trans --keys=app.home.title,app.header.title,menu.label.profile 55 | ``` 56 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@formatjs/intl-relativetimeformat": "^2.8.1", 7 | "@google-cloud/translate": "^4.1.3", 8 | "@testing-library/jest-dom": "^5.16.5", 9 | "@testing-library/react": "^13.4.0", 10 | "@testing-library/user-event": "^13.5.0", 11 | "@types/jest": "^27.5.2", 12 | "@types/node": "^16.11.60", 13 | "@types/react": "^18.0.21", 14 | "@types/react-dom": "^18.0.6", 15 | "axios": "^0.21.1", 16 | "bootstrap": "^4.3.1", 17 | "dotenv": "^8.1.0", 18 | "eslint": "^8.2.0", 19 | "eslint-config-airbnb": "19.0.4", 20 | "formik": "^1.5.8", 21 | "immer": "^8.0.1", 22 | "node-sass": "^6.0.1", 23 | "query-string": "^7.1.1", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "react-icons": "^3.7.0", 27 | "react-intl": "^6.1.1", 28 | "react-loadable": "^5.5.0", 29 | "react-redux": "^7.1.0", 30 | "react-router-dom": "^5.1.2", 31 | "react-scripts": "5.0.1", 32 | "react-toastify": "^5.3.2", 33 | "reactstrap": "^8.0.1", 34 | "redux": "^4.0.4", 35 | "redux-dynamic-middlewares": "^1.0.0", 36 | "redux-promise-middleware": "^6.1.1", 37 | "redux-thunk": "^2.3.0", 38 | "socket.io-client": "^4.5.2", 39 | "typescript": "^4.8.3", 40 | "web-vitals": "^2.1.4", 41 | "yup": "^0.32.11" 42 | }, 43 | "scripts": { 44 | "start": "react-scripts start", 45 | "build": "react-scripts build", 46 | "test": "react-scripts test", 47 | "eject": "react-scripts eject" 48 | }, 49 | "eslintConfig": { 50 | "extends": [ 51 | "react-app", 52 | "react-app/jest" 53 | ] 54 | }, 55 | "browserslist": { 56 | "production": [ 57 | ">0.2%", 58 | "not dead", 59 | "not op_mini all" 60 | ], 61 | "development": [ 62 | "last 1 chrome version", 63 | "last 1 firefox version", 64 | "last 1 safari version" 65 | ] 66 | }, 67 | "devDependencies": { 68 | "@types/react-loadable": "^5.5.6", 69 | "@types/react-router-dom": "^5.3.3", 70 | "@types/socket.io-client": "^3.0.0", 71 | "@types/yup": "^0.32.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tericcabrel/react-starter/55b7196ec75bf9c0546e3ce031c1bb5479a20583/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Node React Starter 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tericcabrel/react-starter/55b7196ec75bf9c0546e3ce031c1bb5479a20583/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tericcabrel/react-starter/55b7196ec75bf9c0546e3ce031c1bb5479a20583/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/assets/icons/flags/de.svg: -------------------------------------------------------------------------------- 1 | 2 | Flag of Germany 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /client/src/assets/icons/flags/en.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/assets/icons/flags/fr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/assets/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/assets/images/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tericcabrel/react-starter/55b7196ec75bf9c0546e3ce031c1bb5479a20583/client/src/assets/images/avatar.png -------------------------------------------------------------------------------- /client/src/assets/images/bg-app.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tericcabrel/react-starter/55b7196ec75bf9c0546e3ce031c1bb5479a20583/client/src/assets/images/bg-app.jpg -------------------------------------------------------------------------------- /client/src/assets/images/bg-profile/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tericcabrel/react-starter/55b7196ec75bf9c0546e3ce031c1bb5479a20583/client/src/assets/images/bg-profile/1.jpg -------------------------------------------------------------------------------- /client/src/assets/images/bg-profile/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tericcabrel/react-starter/55b7196ec75bf9c0546e3ce031c1bb5479a20583/client/src/assets/images/bg-profile/3.jpg -------------------------------------------------------------------------------- /client/src/components/AppError/app-error.scss: -------------------------------------------------------------------------------- 1 | .global-error { 2 | .modal-general-error { 3 | h3 { 4 | font-weight: bold; 5 | } 6 | .ant-modal-content { 7 | background-color: #fff !important; 8 | } 9 | ul{ 10 | padding-left: 0; 11 | li { 12 | text-align: center; 13 | color: red; 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/components/AppError/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, useMemo } from 'react'; 2 | import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; 3 | import { IntlShape, useIntl } from 'react-intl'; 4 | 5 | import { ApplicationError, ObjectOfString } from '../../types/common'; 6 | import { isObject } from '../../utils/helpers'; 7 | 8 | import './app-error.scss'; 9 | 10 | export interface IAppErrorProps { 11 | error: ApplicationError | null; 12 | onClose: () => void; 13 | } 14 | 15 | type ParseErrorFn = (error: ApplicationError | null, intl: IntlShape) => ParseErrorResponse; 16 | 17 | type ParseErrorResponse = { 18 | title: string; 19 | content: string[]; 20 | }; 21 | 22 | const parseError: ParseErrorFn = (error: ApplicationError | null, intl: IntlShape): ParseErrorResponse => { 23 | let title: string = intl.formatMessage({ id: 'app.error.title.default', defaultMessage: 'Application error!' }); 24 | let content: string[] = [ 25 | intl.formatMessage({ id: 'app.error.content.default', defaultMessage: 'Unexpected error occurred when executing the process!' }) 26 | ]; 27 | 28 | if (error) { 29 | const { errorType, message }: ApplicationError = error; 30 | 31 | title = errorType; 32 | if (isObject(message)) { 33 | const data: ObjectOfString = message as ObjectOfString; 34 | const keys: string[] = Object.keys(data); 35 | 36 | content = []; 37 | keys.forEach((key: string): void => { 38 | content.push(...data[key]); 39 | }); 40 | } else { 41 | content = [message as string]; 42 | } 43 | } 44 | 45 | return { title, content }; 46 | }; 47 | 48 | const AppError: FC = ({ error, onClose }: IAppErrorProps): ReactElement => { 49 | const intl: IntlShape = useIntl(); 50 | 51 | const headerTitle: string = intl.formatMessage({ id: 'app.error.header.title', defaultMessage: 'General error' }); 52 | const { title, content }: ParseErrorResponse = useMemo((): ParseErrorResponse => { 53 | return parseError(error, intl); 54 | }, [error, intl]); 55 | 56 | return ( 57 |
58 | onClose()} 62 | className="modal-default modal-general-error" 63 | width={864} 64 | > 65 | 66 | {title} 67 | 68 | 69 |
70 |
    71 | { 72 | content.map((item: string, i: number): ReactElement =>
  • {item}
  • ) 73 | } 74 |
75 |
76 |
77 | 78 |
79 | 80 |
81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export default AppError; 88 | -------------------------------------------------------------------------------- /client/src/components/AppFeature/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | import { FaCheckCircle } from 'react-icons/fa'; 3 | 4 | const AppFeature: FC<{}> = (): ReactElement => ( 5 | 12 | ); 13 | 14 | export default AppFeature; 15 | -------------------------------------------------------------------------------- /client/src/components/AppSpinner/app-spinner.scss: -------------------------------------------------------------------------------- 1 | .app-spinner { 2 | width: 100%; 3 | background-color: #fff; 4 | padding: 15px; 5 | display: flex; 6 | justify-content: center; 7 | margin-bottom: 15px; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/components/AppSpinner/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | import { Spinner } from 'reactstrap'; 3 | 4 | import './app-spinner.scss'; 5 | 6 | const AppSpinner: FC<{}> = (): ReactElement => ( 7 |
8 | 9 |
10 | ); 11 | 12 | export default AppSpinner; 13 | -------------------------------------------------------------------------------- /client/src/components/AuthError/auth-error.scss: -------------------------------------------------------------------------------- 1 | .auth-error { 2 | background-color: red; 3 | color: #fff; 4 | font-size: .8rem; 5 | .close { 6 | top: -4px; 7 | color: #fff; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/components/AuthError/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Fragment, ReactElement, useState } from 'react'; 2 | import { Alert } from 'reactstrap'; 3 | 4 | import './auth-error.scss'; 5 | 6 | export interface IAuthErrorProps { 7 | error: any; 8 | } 9 | 10 | const AuthError: FC = ({ error }: IAuthErrorProps): ReactElement => { 11 | const [showVisible, setShowVisible]: [boolean, Function] = useState(true); 12 | 13 | const onDismiss: (isVisible: boolean) => void = (isVisible: boolean): void => { 14 | setShowVisible(!isVisible); 15 | }; 16 | 17 | return ( 18 | 19 | { error && 20 | // tslint:disable-next-line:jsx-wrap-multiline 21 | onDismiss(showVisible)}> 22 | {error} 23 | 24 | } 25 | 26 | ); 27 | }; 28 | 29 | export default AuthError; 30 | -------------------------------------------------------------------------------- /client/src/components/CountryInfo/CountryColumnItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | 3 | type CountryColumnItemProps = { 4 | label: string, 5 | value?: string, 6 | values?: string[] | number[], 7 | type: 'text' | 'img' | 'array' 8 | }; 9 | 10 | const CountryColumnItem: FC = ( 11 | { label, value, values, type }: CountryColumnItemProps 12 | ): ReactElement => ( 13 | 14 |
15 | {label}: 16 | {type === 'text' && {value}} 17 | {type === 'img' && Flag} 18 | {type === 'array' && values && {values.join(', ')}} 19 |
20 | 21 | ); 22 | 23 | export default CountryColumnItem; 24 | -------------------------------------------------------------------------------- /client/src/components/CountryInfo/CountryColumnList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Fragment, ReactElement } from 'react'; 2 | 3 | type CountryColumnListProps = { 4 | label: string, 5 | subLabels: string[], 6 | values: Object[] 7 | }; 8 | 9 | const CountryColumnList: FC = ( 10 | { label, subLabels, values }: CountryColumnListProps 11 | ): ReactElement => ( 12 | 13 |
{label}
14 |
15 |
    16 | { 17 | values.map((value: any, index: number): ReactElement => ( 18 |
  • 19 |
    {`Item ${index + 1}`}
    20 |
    21 | { Object.keys(value).map((key: string, i: number): ReactElement => ( 22 |
    23 | {subLabels[i]}: 24 | 25 | {Array.isArray(value[key]) ? value[key].join(', ') : value[key]} 26 | 27 |
    28 | ))} 29 |
    30 |
  • 31 | )) 32 | } 33 |
34 |
35 |
36 | ); 37 | 38 | export default CountryColumnList; 39 | -------------------------------------------------------------------------------- /client/src/components/CountryInfo/country-info.scss: -------------------------------------------------------------------------------- 1 | .table-country-info { 2 | tr:first-child { 3 | td { 4 | border-top: none; 5 | } 6 | } 7 | 8 | td.country-column-item { 9 | .content { 10 | display: flex; 11 | justify-content: space-between; 12 | } 13 | img { 14 | width: 64px; 15 | height: 32px; 16 | } 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /client/src/components/CustomInputPassword/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, useState } from 'react'; 2 | import { FormGroup, Input, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; 3 | import { FaEyeSlash, FaLock, FaRegEye } from 'react-icons/fa'; 4 | import { ErrorMessage } from 'formik'; 5 | 6 | import { CustomInputPasswordProps } from '../../types/form'; 7 | 8 | type ShowClickEventFn = (event: React.MouseEvent, show: boolean) => void; 9 | 10 | const CustomInputPassword: FC = ( 11 | { name, value, onBlur, onChange, placeholder }: CustomInputPasswordProps 12 | ): ReactElement => { 13 | const [showPassword, setShowPassword]: [boolean, Function] = useState(false); 14 | 15 | const onShowClick: ShowClickEventFn = (event: React.MouseEvent, show: boolean): void => { 16 | event.preventDefault(); 17 | setShowPassword(!show); 18 | }; 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 34 | 35 | { onShowClick(e, showPassword); }} 37 | > 38 | {showPassword ? : } 39 | 40 | 41 | 42 | 43 | {(errorMessage: string): ReactElement => 44 |
{errorMessage}
} 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default CustomInputPassword; 51 | -------------------------------------------------------------------------------- /client/src/components/ErrorHandler/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {Component, PropsWithChildren} from 'react'; 2 | 3 | interface IState { 4 | errorOccurred: boolean; 5 | } 6 | 7 | interface IProps { } 8 | 9 | class ErrorHandler extends Component, IState> { 10 | state: IState; 11 | 12 | constructor(props: IProps) { 13 | super(props); 14 | this.state = { 15 | errorOccurred: false 16 | }; 17 | } 18 | 19 | componentDidCatch(error: Error, info: object): void { 20 | this.setState({ errorOccurred: true }); 21 | console.log(error, info); 22 | } 23 | 24 | render(): any { 25 | return this.state.errorOccurred ?

Something went wrong!

: this.props.children; 26 | } 27 | } 28 | 29 | export default ErrorHandler; 30 | -------------------------------------------------------------------------------- /client/src/components/Forms/ForgotPassword/form-forgot-password.scss: -------------------------------------------------------------------------------- 1 | .form-forgot-password { 2 | .form-group { 3 | .empty { 4 | height: 19px; 5 | } 6 | } 7 | .forgot-password-text { 8 | font-size: .8rem; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/components/Forms/ForgotPassword/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | import * as Yup from 'yup'; 3 | import { 4 | Button, Form, FormGroup, Input, InputGroup, InputGroupAddon, InputGroupText 5 | } from 'reactstrap'; 6 | import { ErrorMessage, Formik } from 'formik'; 7 | import { FaRegEnvelope } from 'react-icons/fa'; 8 | import { IntlShape } from 'react-intl'; 9 | 10 | import { FormCommonProps } from '../../../types/form'; 11 | import { ForgotPasswordData } from '../../../types/redux'; 12 | 13 | import AuthError from '../../AuthError'; 14 | 15 | import './form-forgot-password.scss'; 16 | 17 | const FormForgotPasswordSchema = Yup.object().shape({ 18 | email: Yup.string() 19 | .email('Invalid address email!') 20 | .required('Required') 21 | }); 22 | 23 | interface FormForgotPasswordProps extends FormCommonProps { 24 | data: ForgotPasswordData; 25 | error: object | null; 26 | onForgotPassword: (data: ForgotPasswordData) => void; 27 | intl: IntlShape; 28 | } 29 | 30 | const FormForgotPassword: FC = ( 31 | { data, error, loading, onForgotPassword, intl }: FormForgotPasswordProps 32 | ): ReactElement => { 33 | return ( 34 | => { 38 | await onForgotPassword(values); 39 | actions.resetForm(); 40 | }} 41 | render={({ 42 | values, 43 | errors, 44 | status, 45 | touched, 46 | handleBlur, 47 | handleChange, 48 | handleSubmit, 49 | isSubmitting, 50 | }: any): ReactElement => ( 51 |
52 | 53 | 54 | 55 | 56 | 57 | 58 | 66 | 67 | 68 | {(errorMessage: string): ReactElement => 69 |
{errorMessage}
} 70 |
71 |
72 | 73 |
74 | 83 |
84 | 85 | )} 86 | /> 87 | ); 88 | }; 89 | 90 | export default FormForgotPassword; 91 | -------------------------------------------------------------------------------- /client/src/components/Forms/Login/form-login.scss: -------------------------------------------------------------------------------- 1 | .form-login { 2 | .form-group { 3 | .empty { 4 | height: 19px; 5 | } 6 | } 7 | .email-form-group { 8 | margin-bottom: 30px; 9 | } 10 | .password-form-group { 11 | margin-bottom: 0; 12 | } 13 | .forgot-password-text { 14 | font-size: .8rem; 15 | } 16 | .btn-login { 17 | margin-top: 35px; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/components/Forms/Register/form-register.scss: -------------------------------------------------------------------------------- 1 | .form-register { 2 | .form-group { 3 | .empty { 4 | height: 19px; 5 | } 6 | } 7 | .forgot-password-text { 8 | font-size: .8rem; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/components/Forms/ResetPassword/form-reset-password.scss: -------------------------------------------------------------------------------- 1 | .form-reset-password { 2 | .form-group { 3 | .empty { 4 | height: 19px; 5 | } 6 | } 7 | .forgot-reset-text { 8 | font-size: .8rem; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/components/Forms/ResetPassword/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | import * as Yup from 'yup'; 3 | import { Button, Form, FormGroup } from 'reactstrap'; 4 | import { Formik } from 'formik'; 5 | 6 | import { FormCommonProps } from '../../../types/form'; 7 | import { ResetPasswordData } from '../../../types/redux'; 8 | 9 | import CustomInputPassword from '../../CustomInputPassword'; 10 | import AuthError from '../../AuthError'; 11 | 12 | import './form-reset-password.scss'; 13 | 14 | const FormResetPasswordSchema = Yup.object().shape({ 15 | password: Yup.string() 16 | .min(6, 'Must be at least 6 characters!') 17 | .required('Required'), 18 | confirmPassword: Yup.string() 19 | .min(6, 'Must be at least 6 characters!') 20 | .oneOf([Yup.ref('password'), null], 'Passwords must match') 21 | .required('Required') 22 | }); 23 | 24 | interface FormResetPasswordProps extends FormCommonProps { 25 | data: ResetPasswordData; 26 | onResetPassword: (data: ResetPasswordData) => void; 27 | } 28 | 29 | const FormResetPassword: FC = ( 30 | { data, error, loading, onResetPassword, intl }: FormResetPasswordProps 31 | ): ReactElement => { 32 | return ( 33 | => { 37 | await onResetPassword(values); 38 | actions.resetForm(); 39 | }} 40 | render={({ 41 | values, 42 | errors, 43 | status, 44 | touched, 45 | handleBlur, 46 | handleChange, 47 | handleSubmit, 48 | isSubmitting, 49 | }: any): ReactElement => ( 50 |
51 | 52 | 53 | 60 | 61 | 62 | 69 | 70 | 71 |
72 | 81 |
82 | 83 | )} 84 | /> 85 | ); 86 | }; 87 | 88 | export default FormResetPassword; 89 | -------------------------------------------------------------------------------- /client/src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | interface LoaderType { 4 | customClass?: string; 5 | } 6 | 7 | const loader: FC = ({ customClass }: LoaderType): React.ReactElement => { 8 | const classes: string = customClass ? customClass : ''; 9 | 10 | return ( 11 |
Loading...
12 | ); 13 | }; 14 | 15 | export default loader; 16 | -------------------------------------------------------------------------------- /client/src/components/LocaleSelector/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'; 3 | import { FormattedMessage } from 'react-intl'; 4 | 5 | import LocaleContext, { LocaleContextType } from '../../containers/LocaleProvider/locale-context'; 6 | import messages from '../../translations/messages'; 7 | 8 | import flag_fr from '../../assets/icons/flags/fr.svg'; 9 | import flag_en from '../../assets/icons/flags/en.svg'; 10 | import flag_de from '../../assets/icons/flags/de.svg'; 11 | import flag_es from '../../assets/icons/flags/es.svg'; 12 | 13 | const flags: { [key: string]: any } = { flag_fr, flag_en, flag_de, flag_es }; 14 | 15 | const renderLocaleDropdownItem: (context: LocaleContextType) => ReactElement[] = ( 16 | context: LocaleContextType 17 | ): ReactElement[] => { 18 | const localeDropdownContent: ReactElement[] = []; 19 | 20 | for (const localeKey in context.locales) { 21 | const lang = context.locales[localeKey]; 22 | 23 | localeDropdownContent.push(( 24 | context.changeLocale(localeKey)} 27 | style={{ display: 'flex', alignItems: 'center' }} 28 | > 29 | 30 | {} 31 | 32 | )); 33 | } 34 | 35 | return localeDropdownContent; 36 | }; 37 | 38 | const LocaleSelector: FC<{}> = (): ReactElement => { 39 | return ( 40 | 41 | { (context: LocaleContextType): ReactElement => ( 42 | 43 | 44 | 45 | {} 46 | 47 | 48 | { 49 | renderLocaleDropdownItem(context) 50 | } 51 | 52 | 53 | )} 54 | 55 | ); 56 | }; 57 | 58 | export default LocaleSelector; 59 | -------------------------------------------------------------------------------- /client/src/containers/App/Dashboard/dashboard.scss: -------------------------------------------------------------------------------- 1 | .dashboard-content { 2 | width: 100%; 3 | .dashboard-header { 4 | padding-bottom: 15px; 5 | width: 100%; 6 | form { 7 | #countrySelect { 8 | width: 300px; 9 | } 10 | } 11 | } 12 | .dashboard-body { 13 | padding: 15px; 14 | background-color: #ffffff; 15 | min-height: 500px; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/containers/App/Dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, Suspense, useEffect, useState } from 'react'; 2 | import { Button, Col, Form, FormGroup, Input, Row } from 'reactstrap'; 3 | import { Dispatch } from 'redux'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | import { IntlShape, useIntl } from 'react-intl'; 6 | import { FormattedMessage } from 'react-intl'; 7 | 8 | import { FilteredCountry } from '../../../types/model'; 9 | import { AppState, RootState } from '../../../types/redux'; 10 | 11 | import { getAllCountriesAction } from '../../../store/app/actions'; 12 | import { getCountryInfoRequestAction } from '../../../store/socket/actions'; 13 | 14 | import withApp from '../../../hofs/withApp'; 15 | 16 | import CountryInfo from '../../../components/CountryInfo'; 17 | import AppSpinner from '../../../components/AppSpinner'; 18 | import Loader from '../../../components/Loader'; 19 | 20 | import './dashboard.scss'; 21 | 22 | const Dashboard: FC<{}> = (): ReactElement => { 23 | const dispatch: Dispatch = useDispatch(); 24 | const intl: IntlShape = useIntl(); 25 | 26 | const { countries, country, loading }: AppState = useSelector((state: RootState): AppState => state.app); 27 | const [selectedCountryCode, setSelectedCountryCode]: [string, any] = useState(''); 28 | 29 | useEffect((): void => { 30 | dispatch(getAllCountriesAction()); 31 | }, [dispatch]); 32 | 33 | const handleCountrySelectChange: any = (event: React.FormEvent): void => { 34 | const element: HTMLSelectElement = event.target as HTMLSelectElement; 35 | 36 | setSelectedCountryCode(element.value); 37 | }; 38 | 39 | const handleButtonClick: any = (event: React.MouseEvent): void => { 40 | event.preventDefault(); 41 | 42 | dispatch(getCountryInfoRequestAction(selectedCountryCode.length > 0 ? selectedCountryCode : null)); 43 | }; 44 | 45 | return ( 46 |
47 | 48 | }> 49 | 50 |
51 |
52 | 53 | handleCountrySelectChange(e)} 58 | value={selectedCountryCode} 59 | > 60 | 63 | { countries.map((country: FilteredCountry, index: number): ReactElement => ( 64 | 65 | ))} 66 | 67 | 68 |
69 | 72 |
73 |
74 |
75 | 76 | {loading && } 77 | 78 |
79 |

80 | {intl.formatMessage({ id: 'app.country.info.title', defaultMessage: 'Country information' })} 81 |

82 |
83 | 84 |
85 | 86 |
87 |
88 |
89 | ); 90 | }; 91 | 92 | export default withApp(Dashboard); 93 | -------------------------------------------------------------------------------- /client/src/containers/App/Profile/profile.scss: -------------------------------------------------------------------------------- 1 | .profile-content { 2 | min-height: 600px; 3 | background-color: #ffffff; 4 | padding: 15px; 5 | .profile-header { 6 | background-size: cover; 7 | background: url("../../../assets/images/bg-profile/1.jpg") no-repeat; 8 | height: 200px; 9 | width: 100%; 10 | } 11 | .profile-body { 12 | width: 100%; 13 | .profile-section { 14 | min-height: 300px; 15 | padding: 15px; 16 | } 17 | .profile-section-1 { 18 | width: 30%; 19 | position: relative; 20 | box-shadow: 5px 0 5px -5px #333; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | text-align: center; 25 | .img-profile { 26 | position: absolute; 27 | top: -70px; 28 | width: 128px; 29 | left: calc(50% - 64px); 30 | } 31 | .social-network { 32 | width: 100%; 33 | display: flex; 34 | justify-content: space-around; 35 | a { 36 | text-decoration: none; 37 | color: #333; 38 | .social-icon { 39 | width: 2em; 40 | height: 2em; 41 | } 42 | } 43 | } 44 | } 45 | .profile-section-2 { 46 | width: 70%; 47 | fieldset { 48 | border-bottom: 1px #ddd solid; 49 | margin-bottom: 10px; 50 | legend { 51 | font-weight: bold; 52 | font-size: 16px; 53 | } 54 | } 55 | p.about-me { 56 | font-size: 14px; 57 | text-align: justify; 58 | } 59 | .table-user-info { 60 | border-top: none; 61 | tr { 62 | border: none; 63 | td { 64 | border: none; 65 | padding: 0.25rem; 66 | font-size: 14px; 67 | } 68 | td:last-child { 69 | font-weight: bold; 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /client/src/containers/Auth/ConfirmAccount/confirm-account.scss: -------------------------------------------------------------------------------- 1 | .app-confirm-account { 2 | .app-confirm-account-content { 3 | min-height: 200px; 4 | padding: 30px; 5 | } 6 | .app-confirm-account-extra { 7 | a { 8 | font-size: .8rem; 9 | text-decoration: none; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/containers/Auth/ConfirmAccount/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, memo, ReactElement, useEffect } from 'react'; 2 | import { Location } from 'history'; 3 | import { Dispatch } from 'redux'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | import { Link, useLocation } from 'react-router-dom'; 6 | import { Alert, Col, Row, Spinner } from 'reactstrap'; 7 | import { FormattedMessage } from 'react-intl'; 8 | import { parse, ParsedQuery } from 'query-string'; 9 | 10 | import { AuthState, RootState } from '../../../types/redux'; 11 | import { resetAuthStateAction, confirmAccountAction } from '../../../store/auth/actions'; 12 | 13 | import withAuth from '../../../hofs/withAuth'; 14 | 15 | import AuthError from '../../../components/AuthError'; 16 | 17 | import './confirm-account.scss'; 18 | 19 | const AlertActivating: FC<{}> = memo((): ReactElement => ( 20 | 21 | 22 | 23 | )); 24 | 25 | const AlertActivated: FC<{}> = memo((): ReactElement => ( 26 | 27 | 31 | 32 | )); 33 | 34 | const ConfirmAccount: FC<{}> = (): ReactElement => { 35 | const { search }: Location = useLocation(); 36 | const dispatch: Dispatch = useDispatch(); 37 | const authState: AuthState = useSelector((state: RootState): AuthState => state.auth); 38 | 39 | useEffect((): void => { 40 | const query: ParsedQuery = parse(search.substring(1)); 41 | // console.log(query); 42 | 43 | dispatch(confirmAccountAction({ token: query.token as string })); 44 | }, [search, dispatch]); 45 | 46 | const resetAuthHandler: () => void = (): void => { 47 | dispatch(resetAuthStateAction()); 48 | }; 49 | 50 | return ( 51 |
52 | 56 | 57 |
58 | {authState.loading && } 59 |

60 | 61 |

62 | 63 |
64 | 65 | {!authState.accountConfirmed && !authState.error && } 66 | {authState.accountConfirmed && } 67 |
68 |
69 | 70 | 71 | 72 |
73 |
74 | 75 |
76 |
77 | ); 78 | }; 79 | 80 | export default withAuth(ConfirmAccount); 81 | -------------------------------------------------------------------------------- /client/src/containers/Auth/ForgotPassword/forgot-password.scss: -------------------------------------------------------------------------------- 1 | .app-forgot-password { 2 | .app-forgot-content { 3 | min-height: 300px; 4 | padding: 30px; 5 | .app-forgot-extra { 6 | margin-top: 15px; 7 | a { 8 | font-size: .8rem; 9 | text-decoration: none; 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/containers/Auth/ForgotPassword/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, MutableRefObject, ReactElement, useEffect, useRef } from 'react'; 2 | import { Dispatch } from 'redux'; 3 | import { Link } from 'react-router-dom'; 4 | import { Col, Row, Spinner } from 'reactstrap'; 5 | import { FormattedMessage, IntlShape, useIntl } from 'react-intl'; 6 | import { toast } from 'react-toastify'; 7 | import { useDispatch, useSelector } from 'react-redux'; 8 | 9 | import { AuthState, ForgotPasswordData, RootState } from '../../../types/redux'; 10 | 11 | import { forgotPasswordAction, resetAuthStateAction } from '../../../store/auth/actions'; 12 | 13 | import withAuth from '../../../hofs/withAuth'; 14 | 15 | import FormForgotPassword from '../../../components/Forms/ForgotPassword'; 16 | 17 | import './forgot-password.scss'; 18 | 19 | type ForgotHandlerFn = (values: ForgotPasswordData) => Promise; 20 | 21 | const defaultData: ForgotPasswordData = { email: '' }; 22 | 23 | const ForgotPassword: FC<{}> = (): ReactElement => { 24 | const dispatch: Dispatch = useDispatch(); 25 | const intl: IntlShape = useIntl(); 26 | 27 | const emailSuccess: MutableRefObject = useRef(false); 28 | const authState: AuthState = useSelector((state: RootState): AuthState => state.auth); 29 | 30 | useEffect((): void => { 31 | const message: string = intl.formatMessage({ 32 | id: 'app.auth.forgot.email.sent', 33 | defaultMessage: 'We have sent you an email with a link to reset your password!' 34 | }); 35 | 36 | if (!emailSuccess.current && authState.success) { 37 | toast(message, { type: 'success', autoClose: 10000 }); 38 | emailSuccess.current = true; 39 | } 40 | }, [authState.success, intl]); 41 | 42 | const forgotHandler: ForgotHandlerFn = async (values: ForgotPasswordData): Promise => { 43 | if (emailSuccess.current) { 44 | emailSuccess.current = false; 45 | } 46 | 47 | // console.log('Form Forgot => ', values); 48 | await dispatch(forgotPasswordAction(values)); 49 | }; 50 | 51 | const resetAuthHandler: () => void = (): void => { 52 | dispatch(resetAuthStateAction()); 53 | }; 54 | 55 | return ( 56 |
57 | 61 | 62 |
63 | {authState.loading && } 64 |

65 | 66 |

67 |

68 | 72 |

73 | 74 | 81 |
82 | 83 | 87 | 88 |
89 |
90 | 91 |
92 |
93 | ); 94 | }; 95 | 96 | export default withAuth(ForgotPassword); 97 | -------------------------------------------------------------------------------- /client/src/containers/Auth/Login/login.scss: -------------------------------------------------------------------------------- 1 | .app-login { 2 | .app-login-content { 3 | min-height: 350px; 4 | display: flex; 5 | .first-side { 6 | width: 40%; 7 | background-color: deepskyblue; 8 | color: #fff; 9 | min-height: 50px; 10 | padding: 15px; 11 | .first-side-section-one { 12 | h2 { 13 | margin-bottom: 0; 14 | } 15 | } 16 | .first-side-section-two { 17 | ul { 18 | margin-left: 0; 19 | list-style: none; 20 | font-size: .8rem; 21 | padding: 0 auto; 22 | 23 | li { 24 | display: flex; 25 | flex-direction: row; 26 | align-items: center; 27 | margin-bottom: 5px; 28 | svg { 29 | margin-right: 5px; 30 | } 31 | } 32 | } 33 | } 34 | } 35 | .second-side { 36 | padding: 30px; 37 | overflow-y: auto; 38 | } 39 | } 40 | .app-login-extra { 41 | display: flex; 42 | justify-content: flex-end; 43 | a { 44 | color: #fff; 45 | text-decoration: none; 46 | font-size: 0.9rem; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/containers/Auth/Register/register.scss: -------------------------------------------------------------------------------- 1 | .app-register { 2 | .app-register-content { 3 | min-height: 475px; 4 | display: flex; 5 | .first-side { 6 | width: 40%; 7 | background-color: deepskyblue; 8 | color: #fff; 9 | min-height: 50px; 10 | padding: 15px; 11 | .first-side-section-one { 12 | h2 { 13 | margin-bottom: 0; 14 | } 15 | } 16 | .first-side-section-two { 17 | ul { 18 | margin-left: 0; 19 | list-style: none; 20 | font-size: .8rem; 21 | padding: 0 auto; 22 | 23 | li { 24 | display: flex; 25 | flex-direction: row; 26 | align-items: center; 27 | margin-bottom: 5px; 28 | svg { 29 | margin-right: 5px; 30 | } 31 | } 32 | } 33 | } 34 | } 35 | .second-side { 36 | padding: 30px; 37 | overflow-y: auto; 38 | } 39 | } 40 | .app-register-extra { 41 | display: flex; 42 | justify-content: flex-end; 43 | a { 44 | font-size: .9rem; 45 | color: #fff; 46 | text-decoration: none; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/containers/Auth/ResetPassword/reset-password.scss: -------------------------------------------------------------------------------- 1 | .app-reset-password { 2 | .app-reset-content { 3 | min-height: 300px; 4 | padding: 30px; 5 | .app-reset-extra { 6 | margin-top: 30px; 7 | overflow-y: auto; 8 | a { 9 | font-size: .8rem; 10 | text-decoration: none; 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/containers/Layout/Footer/footer.scss: -------------------------------------------------------------------------------- 1 | .app-footer { 2 | height: 50px; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | background-color: #222d32; 7 | color: #fff; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/containers/Layout/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | 3 | import './footer.scss'; 4 | 5 | const Footer : FC<{}> = (): ReactElement => { 6 | return ( 7 |
8 |

FOOTER

9 |
10 | ); 11 | }; 12 | 13 | export default Footer; 14 | -------------------------------------------------------------------------------- /client/src/containers/Layout/Header/header.scss: -------------------------------------------------------------------------------- 1 | .app-header { 2 | padding: 0 1rem; 3 | height: 50px; 4 | width: calc(100% - 240px); 5 | left: 240px; 6 | .navbar-brand { 7 | padding-bottom: 0; 8 | padding-top: 0; 9 | } 10 | .user-profile { 11 | .user-avatar { 12 | width: 32px; 13 | height: 32px; 14 | margin-right: 10px; 15 | border-radius: 50%; 16 | } 17 | .user-name { 18 | display: inline-block; 19 | margin-top: 5px; 20 | } 21 | .dropdown-menu { 22 | border-radius: 0; 23 | border-color: #fff; 24 | .dropdown-item { 25 | display: flex; 26 | align-items: center; 27 | .user-header-icon { 28 | margin-right: 15px; 29 | } 30 | } 31 | } 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/containers/Layout/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, useState } from 'react'; 2 | import { Dispatch } from 'redux'; 3 | import { useDispatch } from 'react-redux'; 4 | import { useHistory } from 'react-router'; 5 | import { History } from 'history'; 6 | import { 7 | Collapse, DropdownItem, DropdownMenu, DropdownToggle, Nav, Navbar, NavbarBrand, NavbarToggler, UncontrolledDropdown 8 | } from 'reactstrap'; 9 | import { FaSignOutAlt, FaUserAlt, FaCog } from 'react-icons/fa'; 10 | import { FormattedMessage } from 'react-intl'; 11 | 12 | import { logoutUserAction } from '../../../store/app/actions'; 13 | 14 | import LocaleSelector from '../../../components/LocaleSelector'; 15 | 16 | import './header.scss'; 17 | 18 | import avatar from '../../../assets/images/avatar.png'; 19 | 20 | const Header: FC<{}> = (): ReactElement => { 21 | const dispatch: Dispatch = useDispatch(); 22 | const history: History = useHistory(); 23 | 24 | const [isOpen, setIsOpen]: [boolean, Function] = useState(false); 25 | 26 | const toggle: () => void = (): void => { 27 | setIsOpen((v: boolean): boolean => !v); 28 | }; 29 | 30 | const signOut: any = (): void => { 31 | dispatch(logoutUserAction()); 32 | history.push('/'); 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 43 | 73 | 74 | 75 | ); 76 | }; 77 | 78 | export default Header; 79 | -------------------------------------------------------------------------------- /client/src/containers/Layout/SideBar/side-bar.scss: -------------------------------------------------------------------------------- 1 | .sidebar-container { 2 | width: 240px; 3 | background-color: #222d32; 4 | color: #fff; 5 | height: 100vh; 6 | position: fixed; 7 | .sidebar-header { 8 | height: 50px; 9 | display: flex; 10 | align-items: center; 11 | // border-bottom: solid 1px #ddd; 12 | } 13 | .sidebar-user-info { 14 | padding: 10px; 15 | .user-avatar { 16 | width: 48px; 17 | height: 48px; 18 | border-radius: 50%; 19 | } 20 | a { 21 | text-decoration: none; 22 | color: #fff; 23 | } 24 | .user-status { 25 | font-size: 13px; 26 | } 27 | } 28 | .navigation-title { 29 | background-color: #2c3b41; 30 | color: #ffffff; 31 | height: 35px; 32 | font-size: 13px; 33 | display: flex; 34 | align-items: center; 35 | padding-left: 10px; 36 | } 37 | .menu-navigation { 38 | .main-menu { 39 | .main-menu-item { 40 | .nav-link { 41 | display: flex; 42 | align-items: center; 43 | text-decoration: none; 44 | color: #ffffff; 45 | width: 100%; 46 | cursor: pointer; 47 | position: relative; 48 | .nav-label { 49 | font-size: 14px; 50 | margin-left: .5rem; 51 | } 52 | } 53 | .sub-menu { 54 | display: none; 55 | .main-menu-item { 56 | padding-left: 20px; 57 | } 58 | } 59 | .sub-menu.hidden { 60 | display: none; 61 | transition: 1s; 62 | } 63 | .sub-menu.view { 64 | display: block; 65 | transition: 1s; 66 | } 67 | .nav-caret { 68 | position: absolute; 69 | right: 15px; 70 | } 71 | .nav-caret.nav-caret-down { 72 | transform: rotate(90deg); 73 | transition: .5s; 74 | } 75 | .nav-caret.nav-caret-right { 76 | transition: .5s; 77 | } 78 | } 79 | .main-menu-item.active > a { 80 | background-color: #2c3b41; 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /client/src/containers/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, Suspense } from 'react'; 2 | import { Redirect, Route, RouteComponentProps, RouteProps, Switch } from 'react-router-dom'; 3 | 4 | import { RouteConfig } from '../../types/common'; 5 | import { User } from '../../types/model'; 6 | 7 | import routes from '../../routes'; 8 | 9 | import Sidebar from './SideBar'; 10 | import Loader from '../../components/Loader'; 11 | 12 | import LocalStorageManager from '../../utils/local-storage-manager'; 13 | 14 | import './layout.scss'; 15 | 16 | const AppHeader: any = React.lazy((): Promise => import('./Header')); 17 | // const AppFooter: any = React.lazy((): Promise => import('./Footer')); 18 | 19 | export const PrivateRoute: (props: RouteProps) => any = ({ component, ...rest }: RouteProps): any => { 20 | if (!component) { 21 | throw Error('Error: Component is undefined'); 22 | } 23 | 24 | // JSX Elements have to be uppercase. 25 | const Component: React.ComponentType> | React.ComponentType = component; 26 | 27 | const render: (props: RouteComponentProps) => React.ReactNode = ( 28 | props: RouteComponentProps 29 | ): React.ReactNode => { 30 | const currentUserToken: string|null = LocalStorageManager.getUserAccessToken(); 31 | const userInfo: User|null = LocalStorageManager.getUserInfo(); 32 | 33 | if (userInfo && currentUserToken) { 34 | return ; 35 | } 36 | 37 | return ; 38 | }; 39 | 40 | return (); 41 | }; 42 | 43 | const Layout: FC<{}> = (): ReactElement => { 44 | return ( 45 |
46 | 47 | 48 |
49 |
50 | }> 51 | 52 | 53 |
54 | 55 |
56 | }> 57 | 58 | {routes.map((route: RouteConfig, idx: number): ReactElement|null => { 59 | return route.component ? ( 60 | 66 | ) : (null); 67 | })} 68 | 69 | 70 | 71 |
72 | 73 | {/*
74 | }> 75 | 76 | 77 |
*/} 78 |
79 |
80 | ); 81 | }; 82 | 83 | export default Layout; 84 | -------------------------------------------------------------------------------- /client/src/containers/Layout/layout.scss: -------------------------------------------------------------------------------- 1 | .app-layout { 2 | display: flex; 3 | .app-layout-body { 4 | margin-top: 50px; 5 | min-height: 550px; 6 | width: 100%; 7 | margin-left: 240px; 8 | .main { 9 | padding: 15px; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/containers/LocaleProvider/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * LocaleProvider 4 | * 5 | * this component connects the redux state language locale to the 6 | * IntlProvider component and i18n messages (loaded from `app/translations`) 7 | */ 8 | 9 | import React, { FC, ReactElement, useState } from 'react'; 10 | import { IntlProvider } from 'react-intl'; 11 | 12 | import LocaleContext, { availableLocales, LocaleItem } from './locale-context'; 13 | import { LocaleMessages } from '../../types/common'; 14 | 15 | interface ILocaleProviderProps { 16 | messages: LocaleMessages; 17 | children: React.ReactElement; 18 | } 19 | 20 | interface ILocaleProviderState { 21 | locales: LocaleItem; 22 | locale: string; 23 | } 24 | 25 | const LocaleProvider: FC = ({ messages, children }: ILocaleProviderProps): ReactElement => { 26 | const [data, setData]: [ILocaleProviderState, Function] = useState({ 27 | locales: availableLocales.en, 28 | locale: 'en', 29 | }); 30 | 31 | const changeLocale: (locale: string) => void = (locale: string): void => { 32 | setData({ locale, locales: availableLocales[locale] }); 33 | }; 34 | 35 | return ( 36 | 37 | 38 | {children} 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default LocaleProvider; 45 | -------------------------------------------------------------------------------- /client/src/containers/LocaleProvider/locale-context.tsx: -------------------------------------------------------------------------------- 1 | import React, { Context } from 'react'; 2 | 3 | export type LocaleItem = { 4 | [key: string]: string 5 | }; 6 | 7 | export type Locales = { 8 | [key: string]: LocaleItem 9 | }; 10 | 11 | export type LocaleContextType = { 12 | locales: LocaleItem, 13 | locale: string, 14 | changeLocale: (locale: string) => void 15 | }; 16 | 17 | export const availableLocales: Locales = { 18 | en: { en: 'english', fr: 'french', de: 'german', es: 'spanish' }, 19 | fr: { fr: 'french', en: 'english', de: 'german', es: 'spanish' }, 20 | de: { de: 'german', fr: 'french', en: 'english', es: 'spanish' }, 21 | es: { es: 'spanish', fr: 'french', en: 'english', de: 'german' }, 22 | }; 23 | 24 | const defaultValue: LocaleContextType = { 25 | locales: availableLocales.en, 26 | locale: 'en', 27 | changeLocale: (locale: string): void => {}, 28 | }; 29 | 30 | const context: Context = React.createContext(defaultValue); 31 | 32 | export default context; 33 | -------------------------------------------------------------------------------- /client/src/containers/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | 3 | import './not-found.scss'; 4 | 5 | const NotFound: FC<{}> = (): ReactElement => { 6 | return ( 7 |
8 |

NOT FOUND

9 |
10 | ); 11 | }; 12 | 13 | export default NotFound; 14 | -------------------------------------------------------------------------------- /client/src/containers/NotFound/not-found.scss: -------------------------------------------------------------------------------- 1 | .app-not-found { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /client/src/containers/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 3 | import Loadable from 'react-loadable'; 4 | 5 | import { LocaleMessages } from '../types/common'; 6 | import LocaleProvider from './LocaleProvider'; 7 | 8 | const loading: FC<{}> = (): React.ReactElement =>
Loading...
; 9 | 10 | // Containers 11 | const Layout: any = Loadable({ 12 | loading, 13 | loader: (): Promise => import('./Layout'), 14 | }); 15 | 16 | // Pages 17 | const Login: any = Loadable({ 18 | loading, 19 | loader: (): Promise => import('./Auth/Login'), 20 | }); 21 | 22 | const Register: any = Loadable({ 23 | loading, 24 | loader: (): Promise => import('./Auth/Register'), 25 | }); 26 | 27 | const ConfirmAccount: any = Loadable({ 28 | loading, 29 | loader: (): Promise => import('./Auth/ConfirmAccount'), 30 | }); 31 | 32 | const ForgotPassword: any = Loadable({ 33 | loading, 34 | loader: (): Promise => import('./Auth/ForgotPassword'), 35 | }); 36 | 37 | const ResetPassword: any = Loadable({ 38 | loading, 39 | loader: (): Promise => import('./Auth/ResetPassword'), 40 | }); 41 | 42 | const NotFound: any = Loadable({ 43 | loading, 44 | loader: (): Promise => import('./NotFound'), 45 | }); 46 | 47 | interface IAppProps { 48 | messages: LocaleMessages; 49 | } 50 | 51 | const App: React.FC = ({ messages }: IAppProps): React.ReactElement => { 52 | return ( 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default App; 70 | -------------------------------------------------------------------------------- /client/src/hofs/hofs.scss: -------------------------------------------------------------------------------- 1 | #root { 2 | background-size: cover; 3 | background: url("../assets/images/bg-app.jpg") no-repeat; 4 | } 5 | .auth-container { 6 | height: 100%; 7 | padding-left: 0; 8 | padding-right: 0; 9 | .auth-content { 10 | height: 98%; 11 | } 12 | .app-auth { 13 | height: 100%; 14 | .app-auth-container { 15 | height: 100%; 16 | } 17 | .app-auth-content { 18 | background-color: #fff; 19 | position: relative; 20 | .auth-spinner { 21 | position: absolute; 22 | top: 36px; 23 | right: 35px; 24 | width: 1.5rem; 25 | height: 1.5rem; 26 | } 27 | } 28 | } 29 | } 30 | 31 | .app-container { 32 | 33 | } 34 | -------------------------------------------------------------------------------- /client/src/hofs/withApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, useEffect } from 'react'; 2 | import { Dispatch } from 'redux'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { AppState, RootState } from '../types/redux'; 6 | 7 | import { setGlobalErrorAction } from '../store/app/actions'; 8 | 9 | import ErrorHandler from '../components/ErrorHandler'; 10 | import AppError from '../components/AppError'; 11 | 12 | // styles 13 | import './hofs.scss'; 14 | 15 | // @ts-ignore 16 | const withApp: WithAppType = (Wrapped: FC): () => ReactElement => { 17 | return (): ReactElement => { 18 | // tslint:disable-next-line:react-hooks-nesting 19 | const dispatch: Dispatch = useDispatch(); 20 | // tslint:disable-next-line:react-hooks-nesting 21 | const { error }: AppState = useSelector((state: RootState): AppState => state.app); 22 | 23 | // tslint:disable-next-line:react-hooks-nesting 24 | useEffect((): void => { 25 | const rootId: HTMLElement|null = document.getElementById('root'); 26 | 27 | if (rootId !== null) { 28 | // remove the background image set on the root element 29 | rootId.style.background = 'none'; 30 | } 31 | }, []); 32 | 33 | const resetError: () => void = (): void => { 34 | dispatch(setGlobalErrorAction(null)); 35 | }; 36 | 37 | return ( 38 |
39 | 40 | 41 | 42 | 43 | 44 |
45 | ); 46 | }; 47 | }; 48 | 49 | export default withApp; 50 | -------------------------------------------------------------------------------- /client/src/hofs/withAuth.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, useEffect, useState } from 'react'; 2 | import { Container, Navbar, NavbarBrand, NavbarToggler, Collapse, Nav } from 'reactstrap'; 3 | 4 | import LocaleSelector from '../components/LocaleSelector'; 5 | import ErrorHandler from '../components/ErrorHandler'; 6 | 7 | // styles 8 | import './hofs.scss'; 9 | 10 | import logo from '../assets/icons/logo.svg'; 11 | 12 | type WithAuthType = (component: FC) => () => ReactElement; 13 | 14 | const getWindowHeight: () => number = (): number => { 15 | return Math.max( 16 | document.documentElement.clientHeight, 17 | window.innerHeight || 0 18 | ); 19 | }; 20 | 21 | // @ts-ignore 22 | const withAuth: WithAuthType = (Wrapped: FC): () => ReactElement => { 23 | return (): ReactElement => { 24 | // tslint:disable-next-line:react-hooks-nesting 25 | const [height, setHeight]: [number, Function] = useState(getWindowHeight()); 26 | // tslint:disable-next-line:react-hooks-nesting 27 | const [isOpen, setIsOpen]: [boolean, Function] = useState(false); 28 | 29 | // tslint:disable-next-line:react-hooks-nesting 30 | useEffect((): any => { 31 | const rootId: HTMLElement|null = document.getElementById('root'); 32 | 33 | if (rootId !== null) { 34 | rootId.style.height = `${height}px`; 35 | } 36 | 37 | window.addEventListener('resize', (): void => { 38 | window.requestAnimationFrame((): void => { 39 | setHeight(getWindowHeight()); 40 | }); 41 | }); 42 | 43 | return (): void => { 44 | setHeight(getWindowHeight()); 45 | window.removeEventListener('resize', (): void => { }); 46 | }; 47 | }); 48 | 49 | const toggle: any = (): void => { 50 | setIsOpen((v: boolean): boolean => !v); 51 | }; 52 | 53 | return ( 54 |
55 | 56 | 57 | 58 | React starter 59 | 60 | 61 | 62 | 65 | 66 | 67 | 68 |
69 | 70 | 71 | 72 |
73 |
74 |
75 | ); 76 | }; 77 | }; 78 | 79 | export default withAuth; 80 | -------------------------------------------------------------------------------- /client/src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: #ddd; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | 16 | .btn { 17 | border-radius: 0; 18 | } 19 | .input-group-text { 20 | background-color: #fff; 21 | border-radius: 0; 22 | } 23 | .form-control { 24 | border-radius: 0; 25 | } 26 | .form-mik { 27 | .form-input-error { 28 | font-size: .8rem; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import { Provider } from 'react-redux'; 5 | import { toast } from 'react-toastify'; 6 | 7 | import '@formatjs/intl-relativetimeformat/polyfill'; 8 | import '@formatjs/intl-relativetimeformat/dist/include-aliases'; 9 | import '@formatjs/intl-relativetimeformat/dist/locale-data/en'; 10 | import '@formatjs/intl-relativetimeformat/dist/locale-data/de'; 11 | import '@formatjs/intl-relativetimeformat/dist/locale-data/es'; 12 | import '@formatjs/intl-relativetimeformat/dist/locale-data/fr'; 13 | 14 | import 'bootstrap/dist/css/bootstrap.min.css'; 15 | import 'react-toastify/dist/ReactToastify.css'; 16 | import './index.scss'; 17 | 18 | import reportWebVitals from './reportWebVitals'; 19 | import App from './containers'; 20 | 21 | import { configureStore } from './store'; 22 | 23 | import messages_en from './translations/locales/en.json'; 24 | import messages_fr from './translations/locales/fr.json'; 25 | import messages_de from './translations/locales/de.json'; 26 | import messages_es from './translations/locales/es.json'; 27 | 28 | import { LocaleMessages } from './types/common'; 29 | 30 | const messages: LocaleMessages = { 31 | en: messages_en, 32 | fr: messages_fr, 33 | de: messages_de, 34 | es: messages_es, 35 | }; 36 | 37 | toast.configure({}); 38 | 39 | const root = ReactDOM.createRoot( 40 | document.getElementById('root') as HTMLElement 41 | ); 42 | root.render( 43 | 44 | 45 | , 46 | , 47 | 48 | ); 49 | 50 | // If you want to start measuring performance in your app, pass a function 51 | // to log results (for example: reportWebVitals(console.log)) 52 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 53 | reportWebVitals(); 54 | -------------------------------------------------------------------------------- /client/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /client/src/routes.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | import { RouteConfig } from './types/common'; 4 | 5 | const Dashboard: any = lazy((): Promise => import('./containers/App/Dashboard')); 6 | const Profile: any = lazy((): Promise => import('./containers/App/Profile')); 7 | 8 | const routes: RouteConfig[] = [ 9 | { path: '/app/dashboard', exact: false, name: 'Dashboard', component: Dashboard }, 10 | { path: '/app/profile', exact: false, name: 'Profile', component: Profile }, 11 | ]; 12 | 13 | export default routes; 14 | -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /client/src/store/app/actionTypes.ts: -------------------------------------------------------------------------------- 1 | // Global error 2 | export const APP_GLOBAL_ERROR: string = 'APP_GLOBAL_ERROR'; 3 | 4 | // User profile 5 | export const USER_GET_CURRENT: string = 'USER_GET_CURRENT'; 6 | export const USER_UPDATE_PASSWORD: string = 'USER_UPDATE_PASSWORD'; 7 | export const USER_UPDATE_PICTURE: string = 'USER_UPDATE_PICTURE'; 8 | export const USER_UPDATE_USER: string = 'USER_UPDATE_USER'; 9 | export const USER_LOGOUT: string = 'USER_LOGOUT'; 10 | 11 | // Country 12 | export const GET_ALL_COUNTRIES: string = 'GET_ALL_COUNTRIES'; 13 | -------------------------------------------------------------------------------- /client/src/store/app/actions.ts: -------------------------------------------------------------------------------- 1 | import http, { AxiosResponse } from 'axios'; 2 | 3 | import { ApplicationError } from '../../types/common'; 4 | import { ReduxAction } from '../../types/redux'; 5 | import { SetGlobalErrorActionFn, VoidActionFn } from '../../types/function'; 6 | 7 | import * as actionTypes from './actionTypes'; 8 | 9 | import httpClient from '../../utils/http-client'; 10 | import LocalStorageManager from '../../utils/local-storage-manager'; 11 | 12 | export const setGlobalErrorAction: SetGlobalErrorActionFn = (data: ApplicationError | null): ReduxAction => { 13 | return { 14 | type: actionTypes.APP_GLOBAL_ERROR, 15 | payload: data 16 | }; 17 | }; 18 | 19 | export const getUserAction: VoidActionFn = (): ReduxAction => { 20 | return { 21 | type: actionTypes.USER_GET_CURRENT, 22 | async payload(): Promise { 23 | try { 24 | const res: AxiosResponse = await httpClient.get('users/me'); 25 | LocalStorageManager.saveUserInfo(res.data); 26 | 27 | return res.data; 28 | } catch (error) { 29 | return Promise.reject(error); 30 | } 31 | } 32 | }; 33 | }; 34 | 35 | export const logoutUserAction: VoidActionFn = (): ReduxAction => { 36 | LocalStorageManager.logoutUser(); 37 | 38 | return { 39 | type: actionTypes.USER_LOGOUT, 40 | payload: null 41 | }; 42 | }; 43 | 44 | export const getAllCountriesAction: VoidActionFn = (): ReduxAction => { 45 | return { 46 | type: actionTypes.GET_ALL_COUNTRIES, 47 | async payload(): Promise { 48 | try { 49 | const res: AxiosResponse = await http.get(`${process.env.REACT_APP_REST_COUNTRY_BASE_URL}/all?fields=name,alpha2Code`); 50 | 51 | return res.data; 52 | } catch (error) { 53 | return Promise.reject(error); 54 | } 55 | } 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /client/src/store/app/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import producer from 'immer'; 3 | 4 | import * as actionTypes from './actionTypes'; 5 | import * as socketActionTypes from '../socket/actionTypes'; 6 | 7 | import { ReduxAction, AppState } from '../../types/redux'; 8 | import { ApplicationError } from '../../types/common'; 9 | import { Country } from '../../types/model'; 10 | 11 | import LocalStorageManager from '../../utils/local-storage-manager'; 12 | 13 | const initialState: AppState = { 14 | loading: false, 15 | error: null, 16 | user: LocalStorageManager.getUserInfo(), 17 | country: null, 18 | countries: [] 19 | }; 20 | 21 | export const appReducer: Reducer = ( 22 | state: AppState = initialState, action: ReduxAction 23 | ): AppState => { 24 | const { type, payload }: any = action; 25 | 26 | return producer(state, (draft: AppState): void => { 27 | switch (type) { 28 | // Global error 29 | case actionTypes.APP_GLOBAL_ERROR: 30 | draft.error = payload as ApplicationError; 31 | draft.loading = false; 32 | break; 33 | 34 | // User 35 | case `${actionTypes.USER_GET_CURRENT}_FULFILLED`: 36 | draft.user = payload; 37 | break; 38 | case actionTypes.USER_LOGOUT: 39 | draft.user = null; 40 | break; 41 | 42 | // Get all countries 43 | case `${actionTypes.GET_ALL_COUNTRIES}_FULFILLED`: 44 | draft.countries = payload as Country[]; 45 | break; 46 | case `${actionTypes.GET_ALL_COUNTRIES}_REJECTED`: 47 | draft.error = payload as any; 48 | break; 49 | 50 | case socketActionTypes.GET_COUNTRY_INFO_REQUEST: 51 | draft.loading = true; 52 | break; 53 | 54 | case socketActionTypes.GET_COUNTRY_INFO_RESPONSE: 55 | draft.loading = false; 56 | draft.country = payload as Country; 57 | break; 58 | } 59 | }); 60 | }; 61 | 62 | export default appReducer; 63 | -------------------------------------------------------------------------------- /client/src/store/auth/actionTypes.ts: -------------------------------------------------------------------------------- 1 | // Auth 2 | export const AUTH_RESET_STATE: string = 'AUTH_RESET_STATE'; 3 | 4 | // User authentication 5 | export const USER_REGISTER: string = 'USER_REGISTER'; 6 | export const USER_CONFIRM_ACCOUNT: string = 'USER_CONFIRM_ACCOUNT'; 7 | export const USER_LOGIN: string = 'USER_LOGIN'; 8 | export const USER_FORGOT_PASSWORD: string = 'USER_FORGOT_PASSWORD'; 9 | export const USER_RESET_PASSWORD: string = 'USER_RESET_PASSWORD'; 10 | -------------------------------------------------------------------------------- /client/src/store/auth/actions.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | import httpClient from '../../utils/http-client'; 3 | import * as actionTypes from './actionTypes'; 4 | import { 5 | ConfirmAccountData, LoginSuccessResponse, ReduxAction, 6 | RegisterData, LoginData, ForgotPasswordData, ResetPasswordVerifyData 7 | } from '../../types/redux'; 8 | import LocalStorageManager from '../../utils/local-storage-manager'; 9 | import { 10 | ConfirmAccountActionFn, ForgotPasswordActionFn, LoginUserActionFn, 11 | RegisterUserActionFn, ResetAuthStateActionFn, ResetPasswordActionFn 12 | } from '../../types/function'; 13 | 14 | export const resetAuthStateAction: ResetAuthStateActionFn = (): ReduxAction => { 15 | return { 16 | type: actionTypes.AUTH_RESET_STATE, 17 | payload: null, 18 | }; 19 | }; 20 | 21 | export const registerUserAction: RegisterUserActionFn = (data: RegisterData): ReduxAction => { 22 | return { 23 | type: actionTypes.USER_REGISTER, 24 | async payload(): Promise { 25 | try { 26 | const res: AxiosResponse = await httpClient.post('auth/register', data); 27 | 28 | return res.data; 29 | } catch (error) { 30 | return Promise.reject(error); 31 | } 32 | } 33 | }; 34 | }; 35 | 36 | export const confirmAccountAction: ConfirmAccountActionFn = (data: ConfirmAccountData): ReduxAction => { 37 | return { 38 | type: actionTypes.USER_CONFIRM_ACCOUNT, 39 | async payload(): Promise { 40 | try { 41 | const res: AxiosResponse = await httpClient.post('auth/account/confirm', data); 42 | 43 | return res.data; 44 | } catch (error) { 45 | return Promise.reject(error); 46 | } 47 | } 48 | }; 49 | }; 50 | 51 | export const loginUserAction: LoginUserActionFn = (data: LoginData): ReduxAction => { 52 | return { 53 | type: actionTypes.USER_LOGIN, 54 | async payload(): Promise { 55 | try { 56 | const res: AxiosResponse = await httpClient.post('auth/login', data); 57 | const loginResponse: LoginSuccessResponse = res.data; 58 | 59 | LocalStorageManager.saveUserAccessToken(loginResponse.token, loginResponse.expiresIn / (3600 * 24)); 60 | LocalStorageManager.saveUserRefreshToken(loginResponse.refreshToken); 61 | 62 | return res.data; 63 | } catch (error) { 64 | return Promise.reject(error); 65 | } 66 | } 67 | }; 68 | }; 69 | 70 | export const forgotPasswordAction: ForgotPasswordActionFn = (data: ForgotPasswordData): ReduxAction => { 71 | return { 72 | type: actionTypes.USER_FORGOT_PASSWORD, 73 | async payload(): Promise { 74 | try { 75 | const res: AxiosResponse = await httpClient.post('auth/password/forgot', data); 76 | 77 | return res.data; 78 | } catch (error) { 79 | return Promise.reject(error); 80 | } 81 | } 82 | }; 83 | }; 84 | 85 | export const resetPasswordAction: ResetPasswordActionFn = (data: ResetPasswordVerifyData): ReduxAction => { 86 | return { 87 | type: actionTypes.USER_RESET_PASSWORD, 88 | async payload(): Promise { 89 | try { 90 | const res: AxiosResponse = await httpClient.post('auth/password/reset', data); 91 | 92 | return res.data; 93 | } catch (error) { 94 | return Promise.reject(error); 95 | } 96 | } 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /client/src/store/auth/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import producer from 'immer'; 3 | 4 | import { ReduxAction, AuthState } from '../../types/redux'; 5 | 6 | import * as actionTypes from './actionTypes'; 7 | 8 | const initialState: AuthState = { 9 | loading: false, 10 | success: false, 11 | accountConfirmed: false, 12 | error: null, // 'Internal server error' 13 | }; 14 | 15 | const authReducer: Reducer = ( 16 | state: AuthState = initialState, action: ReduxAction 17 | ): AuthState => { 18 | const { type, payload }: any = action; 19 | 20 | return producer(state, (draft: AuthState): void => { 21 | switch (type) { 22 | // Reset state's values 23 | case actionTypes.AUTH_RESET_STATE: 24 | draft.error = initialState.error; 25 | break; 26 | 27 | // Registration 28 | case `${actionTypes.USER_REGISTER}_PENDING`: 29 | draft.loading = true; 30 | draft.success = false; 31 | draft.error = null; 32 | break; 33 | case `${actionTypes.USER_REGISTER}_FULFILLED`: 34 | draft.loading = false; 35 | draft.success = true; 36 | break; 37 | case `${actionTypes.USER_REGISTER}_REJECTED`: 38 | draft.loading = false; 39 | draft.error = payload; 40 | break; 41 | 42 | // Account confirmation 43 | case `${actionTypes.USER_CONFIRM_ACCOUNT}_PENDING`: 44 | draft.loading = true; 45 | draft.accountConfirmed = false; 46 | draft.error = null; 47 | break; 48 | case `${actionTypes.USER_CONFIRM_ACCOUNT}_FULFILLED`: 49 | draft.loading = false; 50 | draft.accountConfirmed = true; 51 | break; 52 | case `${actionTypes.USER_CONFIRM_ACCOUNT}_REJECTED`: 53 | draft.loading = false; 54 | draft.error = payload; 55 | break; 56 | 57 | // Authentication 58 | case `${actionTypes.USER_LOGIN}_PENDING`: 59 | draft.loading = true; 60 | draft.error = null; 61 | break; 62 | case `${actionTypes.USER_LOGIN}_FULFILLED`: 63 | draft.loading = false; 64 | break; 65 | case `${actionTypes.USER_LOGIN}_REJECTED`: 66 | draft.loading = false; 67 | draft.error = payload; 68 | break; 69 | 70 | // Forgot password 71 | case `${actionTypes.USER_FORGOT_PASSWORD}_PENDING`: 72 | draft.loading = true; 73 | draft.success = false; 74 | draft.error = null; 75 | break; 76 | case `${actionTypes.USER_FORGOT_PASSWORD}_FULFILLED`: 77 | draft.loading = false; 78 | draft.success = true; 79 | break; 80 | case `${actionTypes.USER_FORGOT_PASSWORD}_REJECTED`: 81 | draft.loading = false; 82 | draft.success = false; 83 | draft.error = payload; 84 | break; 85 | 86 | // Reset password 87 | case `${actionTypes.USER_RESET_PASSWORD}_PENDING`: 88 | draft.loading = true; 89 | draft.success = false; 90 | draft.error = null; 91 | break; 92 | case `${actionTypes.USER_RESET_PASSWORD}_FULFILLED`: 93 | draft.loading = false; 94 | draft.success = true; 95 | break; 96 | case `${actionTypes.USER_RESET_PASSWORD}_REJECTED`: 97 | draft.loading = false; 98 | draft.success = false; 99 | draft.error = payload; 100 | break; 101 | } 102 | }); 103 | }; 104 | 105 | export default authReducer; 106 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose, Store, combineReducers, Reducer } from 'redux'; 2 | import promise from 'redux-promise-middleware'; 3 | import thunk from 'redux-thunk'; 4 | // @ts-ignore 5 | import dynamicMiddlewares, { addMiddleware } from 'redux-dynamic-middlewares'; 6 | import { io, Socket } from 'socket.io-client'; 7 | 8 | // Redux socket utilities 9 | import socketIOEmitterMiddleware from './socket/socketIOEmitter'; 10 | import socketIOListener from './socket/socketIOListener'; 11 | 12 | // Reducers 13 | import AuthReducer from './auth/reducer'; 14 | import AppReducer from './app/reducer'; 15 | import SocketReducer from './socket/reducer'; 16 | 17 | // Types 18 | import { ReduxAction, RootState } from '../types/redux'; 19 | import { ConfigureStoreFn } from '../types/function'; 20 | 21 | declare var window: any; 22 | 23 | const middlewares: any[] = [dynamicMiddlewares, thunk, promise]; 24 | 25 | const socketUrl: string|undefined = process.env.REACT_APP_SOCKET_URL; 26 | const socketPath: string|undefined = process.env.REACT_APP_SOCKET_PATH; 27 | 28 | // combined reducers 29 | const reducers: Reducer = combineReducers({ 30 | auth: AuthReducer, 31 | app: AppReducer, 32 | socket: SocketReducer, 33 | }); 34 | 35 | export const configureStore: ConfigureStoreFn = (initialState?: RootState): Store => { 36 | let composeEnhancers: any; 37 | 38 | if (process.env.NODE_ENV !== 'production') { 39 | composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 40 | } else { 41 | composeEnhancers = compose; 42 | } 43 | 44 | const store: Store = createStore( 45 | reducers, 46 | initialState, 47 | composeEnhancers( 48 | applyMiddleware(...middlewares) 49 | ) 50 | ); 51 | 52 | // Configure socket 53 | let socket: Socket; 54 | 55 | const trySocketConnect: () => void = (): void => { 56 | if (socket) socket.close(); 57 | 58 | if (!socketPath) { 59 | socket = io(`${socketUrl}`); 60 | } else { 61 | socket = io(`${socketUrl}`, { path: socketPath }); 62 | } 63 | socketIOListener(socket, store, trySocketConnect); 64 | 65 | socket.on('connect', (): void => { 66 | addMiddleware(socketIOEmitterMiddleware(socket)); 67 | }); 68 | }; 69 | 70 | trySocketConnect(); 71 | 72 | return store; 73 | }; 74 | -------------------------------------------------------------------------------- /client/src/store/socket/actionTypes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * socket actionTypes 4 | * 5 | */ 6 | 7 | export const SOCKET_CONNECTED: string = 'SOCKET_CONNECTED'; 8 | export const SOCKET_DISCONNECTED: string = 'SOCKET_DISCONNECTED'; 9 | 10 | export const GET_COUNTRY_INFO_REQUEST: string = 'SCK_GET_COUNTRY_INFO_REQUEST'; 11 | export const GET_COUNTRY_INFO_RESPONSE: string = 'GET_COUNTRY_INFO_RESPONSE'; 12 | -------------------------------------------------------------------------------- /client/src/store/socket/actions.ts: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes'; 2 | import { SOCKET_REQUEST_EVENT } from '../../utils/constants'; 3 | import { ReduxAction, SocketRequestAction } from '../../types/redux'; 4 | import { Country } from '../../types/model'; 5 | import { 6 | GetCountryInfoRequestActionFn, GetCountryInfoResponseActionFn, VoidActionFn 7 | } from '../../types/function'; 8 | 9 | export const socketConnectedAction: VoidActionFn = (): ReduxAction => { 10 | return { 11 | type: actionTypes.SOCKET_CONNECTED, 12 | payload: null 13 | }; 14 | }; 15 | 16 | export const socketDisconnectedAction: VoidActionFn = (): ReduxAction => { 17 | return { 18 | type: actionTypes.SOCKET_DISCONNECTED, 19 | payload: null 20 | }; 21 | }; 22 | 23 | export const getCountryInfoRequestAction: GetCountryInfoRequestActionFn = (countryCode: string|null): ReduxAction => { 24 | return { 25 | type: actionTypes.GET_COUNTRY_INFO_REQUEST, 26 | payload: JSON.stringify({ 27 | rqid: SocketRequestAction.GET_COUNTRY_INFO_REQUEST, 28 | code: countryCode 29 | }), 30 | meta: { 31 | socket: { 32 | channel: SOCKET_REQUEST_EVENT 33 | } 34 | } 35 | }; 36 | }; 37 | 38 | export const getCountryInfoResponseAction: GetCountryInfoResponseActionFn = (data: Country | null): ReduxAction => { 39 | return { 40 | type: actionTypes.GET_COUNTRY_INFO_RESPONSE, 41 | payload: data 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /client/src/store/socket/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import producer from 'immer'; 3 | 4 | import * as actionTypes from './actionTypes'; 5 | import { ReduxAction, SocketState } from '../../types/redux'; 6 | 7 | const initialState: SocketState = { 8 | connected: false 9 | }; 10 | 11 | export const socketReducer: Reducer = ( 12 | state: SocketState = initialState, action: ReduxAction 13 | ): SocketState => { 14 | const { type }: ReduxAction = action; 15 | 16 | return producer(state, (draft: SocketState): void => { 17 | switch (type) { 18 | case actionTypes.SOCKET_CONNECTED: 19 | draft.connected = true; 20 | break; 21 | case actionTypes.SOCKET_DISCONNECTED: 22 | draft.connected = false; 23 | break; 24 | } 25 | }); 26 | }; 27 | 28 | export default socketReducer; 29 | -------------------------------------------------------------------------------- /client/src/store/socket/socketIOEmitter.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'redux'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | import { ReduxAction } from '../../types/redux'; 5 | 6 | const socketIOEmitter: any = (socket: Socket): any => (obj: any): any => { 7 | return (next: Dispatch): any => (action: ReduxAction): any => { 8 | // Capture socket's action only. The action's type are prefixed by SCK 9 | if (action.type.startsWith('SCK')) { 10 | console.log('Socket data sent : ', action); 11 | } 12 | 13 | if (action.meta && action.meta.socket && action.meta.socket.channel) { 14 | socket.emit(action.meta.socket.channel, action.payload); 15 | } 16 | 17 | return next(action); 18 | }; 19 | }; 20 | 21 | export default socketIOEmitter; 22 | -------------------------------------------------------------------------------- /client/src/store/socket/socketIOListener.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | import { ApplicationError } from '../../types/common'; 5 | import { Country } from '../../types/model'; 6 | import { ReduxAction, SocketRequestAction, SocketResponse, RootState } from '../../types/redux'; 7 | import { ConfigureSocketFn } from '../../types/function'; 8 | 9 | import { socketDisconnectedAction, socketConnectedAction, getCountryInfoResponseAction } from './actions'; 10 | import { setGlobalErrorAction } from '../app/actions'; 11 | 12 | import { parseSocketResponseMessage } from '../../utils/helpers'; 13 | import { SOCKET_RESPONSE_EVENT } from '../../utils/constants'; 14 | 15 | const socketConfigure: ConfigureSocketFn = ( 16 | socket: Socket, 17 | store: Store, 18 | trySocketConnect: () => void 19 | ): void => { 20 | socket.on('connect', (): void => { 21 | console.log('Connected to socket server !'); 22 | store.dispatch(socketConnectedAction()); 23 | }); 24 | 25 | socket.on(SOCKET_RESPONSE_EVENT, (data: string): void => { 26 | console.log('Socket Response : ', data); 27 | 28 | const response : SocketResponse | ApplicationError = parseSocketResponseMessage(data); 29 | 30 | if (response.hasOwnProperty('errorType')) { 31 | store.dispatch(setGlobalErrorAction(response as ApplicationError)); 32 | 33 | return; 34 | } 35 | 36 | // const appState = store.getState().app; 37 | const socketResponse: SocketResponse = response as SocketResponse; 38 | 39 | switch (socketResponse.rqid) { 40 | case SocketRequestAction.GET_COUNTRY_INFO_REQUEST: 41 | store.dispatch(getCountryInfoResponseAction(socketResponse.data as Country)); 42 | break; 43 | default: 44 | console.log('Unknown socket action !'); 45 | break; 46 | } 47 | }); 48 | 49 | socket.on('disconnect', (): void => { 50 | store.dispatch(socketDisconnectedAction()); 51 | trySocketConnect(); 52 | }); 53 | }; 54 | 55 | export default socketConfigure; 56 | -------------------------------------------------------------------------------- /client/src/translations/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from 'react-intl'; 2 | 3 | export default defineMessages({ 4 | english: { 5 | id: 'app.locale.english', 6 | defaultMessage: 'English', 7 | }, 8 | french: { 9 | id: 'app.locale.french', 10 | defaultMessage: 'French', 11 | }, 12 | german: { 13 | id: 'app.locale.german', 14 | defaultMessage: 'German', 15 | }, 16 | spanish: { 17 | id: 'app.locale.spanish', 18 | defaultMessage: 'Spanish', 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /client/src/types/common.ts: -------------------------------------------------------------------------------- 1 | export type LocaleMessages = { 2 | [key: string]: { 3 | [prop: string]: string 4 | } 5 | }; 6 | 7 | export type ObjectOfString = { [key: string]: [string] }; 8 | 9 | export type ApplicationError = { 10 | errorType: string, 11 | message: ObjectOfString | string, 12 | }; 13 | 14 | export type RouteConfig = { 15 | path: string; 16 | exact: boolean; 17 | name: string; 18 | component: any; 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/types/form.ts: -------------------------------------------------------------------------------- 1 | import { IntlShape } from 'react-intl'; 2 | 3 | export interface CustomInputPasswordProps { 4 | name: string; 5 | value: string; 6 | placeholder: string; 7 | onBlur: (e: React.FocusEvent) => void; 8 | onChange: (e: React.ChangeEvent) => void; 9 | } 10 | 11 | export interface FormCommonProps { 12 | loading: boolean; 13 | error: object | null; 14 | intl: IntlShape; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/types/function.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | import { 5 | ConfirmAccountData, 6 | ForgotPasswordData, 7 | LoginData, 8 | ReduxAction, 9 | RegisterData, 10 | ResetPasswordVerifyData, 11 | RootState 12 | } from './redux'; 13 | import { ApplicationError } from './common'; 14 | import { Country } from './model'; 15 | 16 | export type ConfigureStoreFn = (initialState?: RootState) => Store; 17 | export type ConfigureSocketFn = ( 18 | socket: Socket, store: Store, trySocketConnect: () => void 19 | ) => void; 20 | 21 | export type VoidActionFn = () => ReduxAction; 22 | export type ResetAuthStateActionFn = () => ReduxAction; 23 | export type RegisterUserActionFn = (data: RegisterData) => ReduxAction; 24 | export type ConfirmAccountActionFn = (data: ConfirmAccountData) => ReduxAction; 25 | export type LoginUserActionFn = (data: LoginData) => ReduxAction; 26 | export type ForgotPasswordActionFn = (data: ForgotPasswordData) => ReduxAction; 27 | export type ResetPasswordActionFn = (data: ResetPasswordVerifyData) => ReduxAction; 28 | export type SetGlobalErrorActionFn = (data: ApplicationError | null) => ReduxAction; 29 | 30 | export type GetCountryInfoRequestActionFn = (countryCode: string|null) => ReduxAction; 31 | export type GetCountryInfoResponseActionFn = (data: Country | null) => ReduxAction; 32 | -------------------------------------------------------------------------------- /client/src/types/model.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | _id: string; 3 | name: string; 4 | email: string; 5 | avatar: string|null; 6 | username: string; 7 | gender: string; 8 | confirmed: boolean; 9 | created_at: string; 10 | updated_at: string; 11 | }; 12 | 13 | export type Currency = { 14 | code: string, 15 | name: string, 16 | symbol: string 17 | }; 18 | 19 | export type Language = { 20 | iso639_1: string, 21 | iso639_2: string, 22 | name: string, 23 | nativeName: string 24 | }; 25 | 26 | export type RegionBloc = { 27 | acronym: string, 28 | name: string, 29 | otherAcronyms: string[], 30 | otherNames: string[] 31 | }; 32 | 33 | export type Country = { 34 | name: string, 35 | topLevelDomain: string[], 36 | alpha2Code: string, 37 | alpha3Code: string, 38 | callingCodes: string[], 39 | capital: string, 40 | altSpellings: string[], 41 | region: string, 42 | subregion: string, 43 | population: number, 44 | latlng: number[], 45 | demonym: string, 46 | area: number, 47 | gini: number, 48 | timezones: string[], 49 | borders: string[], 50 | nativeName: string, 51 | numericCode: string, 52 | currencies: Currency[], 53 | languages: Language[], 54 | translations: { 55 | [key: string]: string 56 | }, 57 | flag: string, 58 | regionalBlocs: RegionBloc[], 59 | cioc: string 60 | }; 61 | 62 | export type FilteredCountry = { 63 | name: string, 64 | alpha2Code: string 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/types/redux.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | 3 | import { ApplicationError } from './common'; 4 | import { Country, FilteredCountry, User } from './model'; 5 | 6 | export interface ReduxAction extends Action { 7 | type: string; 8 | payload: any; 9 | meta?: { 10 | socket: { 11 | channel: string, 12 | } 13 | }; 14 | } 15 | 16 | export enum SocketRequestAction { 17 | GET_COUNTRY_INFO_REQUEST = 'SCK001', 18 | } 19 | 20 | export type RootState = { 21 | auth: AuthState, 22 | app: AppState, 23 | socket: SocketState, 24 | }; 25 | 26 | export type AuthState = { 27 | loading: boolean, 28 | success: boolean, 29 | accountConfirmed: boolean, 30 | error: Object | null, 31 | }; 32 | 33 | export type AppState = { 34 | loading: boolean, 35 | error: ApplicationError | null, 36 | user: User | null, 37 | country: Country | null, 38 | countries: FilteredCountry[] 39 | }; 40 | 41 | export type SocketState = { 42 | connected: boolean; 43 | }; 44 | 45 | export type SocketResponse = { 46 | rqid: string, 47 | data: Object, 48 | error?: { [key: string]: [string] } | null 49 | }; 50 | 51 | export type LoginData = { 52 | email: string, 53 | password: string 54 | }; 55 | 56 | export type RegisterData = { 57 | email: string, 58 | password: string, 59 | username: string, 60 | name: string, 61 | confirmPassword: string 62 | }; 63 | 64 | export type ForgotPasswordData = { 65 | email: string 66 | }; 67 | 68 | export type ResetPasswordData = { 69 | password: string, 70 | confirmPassword: string 71 | }; 72 | 73 | /** 74 | * When the server return the 422 status code, the errors has this format 75 | * Eg: { errors: { "email": ["This email already taken"], name: ["At least 6 characters", ...], .... }} 76 | */ 77 | export type InvalidDataResponse = { 78 | errors: { 79 | [key: string]: string[] 80 | } 81 | }; 82 | 83 | export type ConfirmAccountData = { 84 | token: string 85 | }; 86 | 87 | export type LoginSuccessResponse = { 88 | token: string, 89 | expiresIn: number, 90 | refreshToken: string 91 | }; 92 | 93 | export type ResetPasswordVerifyData = { 94 | password: string, 95 | reset_token: string|null 96 | }; 97 | -------------------------------------------------------------------------------- /client/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const USER_DATA_KEY: string = 'usrdatatk'; 2 | export const USER_ACCESS_TOKEN_KEY: string = 'usracsstk'; 3 | export const USER_REFRESH_TOKEN_KEY: string = 'usrrfrsstk'; 4 | 5 | export const SOCKET_REQUEST_EVENT: string = 'react_start_req'; 6 | export const SOCKET_RESPONSE_EVENT: string = 'react_start_res'; 7 | -------------------------------------------------------------------------------- /client/src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { SocketResponse } from '../types/redux'; 2 | import { ApplicationError } from '../types/common'; 3 | 4 | /** 5 | * Takes the response return by the socket and parse to check if 6 | * it an error or success response 7 | * 8 | * @function 9 | * @param {string} message - Message returned by the socket. 10 | * @return SocketResponse|ApplicationError 11 | */ 12 | export const parseSocketResponseMessage: Function = (message: string): SocketResponse | ApplicationError => { 13 | try { 14 | const object: SocketResponse = JSON.parse(message); 15 | 16 | if (object.error) { 17 | return { 18 | errorType: 'Socket Error', 19 | message: object.error 20 | }; 21 | } 22 | 23 | return object; 24 | } catch (e) { 25 | console.log('Response Parsing Error Message => ', e); 26 | 27 | return { 28 | errorType: 'Socket Response Parsing Error ', 29 | message: { details: [e as any], response: [message] } 30 | }; 31 | } 32 | }; 33 | 34 | /** 35 | * Makes to uppercase the first letter of a word 36 | * 37 | * @function 38 | * @param {string} word - Word to capitalize 39 | * @return string 40 | */ 41 | export const capitalize: (word: string) => string = (word: string): string => { 42 | if (word.length === 0) { 43 | return word; 44 | } 45 | 46 | return word.charAt(0).toUpperCase() + word.slice(1); 47 | }; 48 | 49 | /** 50 | * Check if a variable is an object or not 51 | * 52 | * @function 53 | * @param {any} obj - Parameter to check 54 | * @return boolean 55 | */ 56 | export const isObject: (obj: any) => boolean = (obj: any): boolean => { 57 | return (typeof obj === 'object' && obj !== null) || typeof obj === 'function'; 58 | }; 59 | -------------------------------------------------------------------------------- /client/src/utils/http-client.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import LocalStorageManager from './local-storage-manager'; 3 | import { parseHTTPResponse } from './http-reponse-parser'; 4 | 5 | const instance: AxiosInstance = axios.create({ 6 | baseURL : process.env.REACT_APP_API_BASE_URL 7 | }); 8 | 9 | instance.defaults.headers.post['Content-Type'] = 'application/json'; 10 | 11 | instance.interceptors.request.use((request: AxiosRequestConfig): AxiosRequestConfig => { 12 | // You can customize the request here like encrypting data before it's sent to the server 13 | const token: string|null = LocalStorageManager.getUserAccessToken(); 14 | 15 | if (token) { 16 | request.headers.common['x-access-token'] = token; 17 | } 18 | 19 | return request; 20 | }, (error: AxiosError): Promise => { 21 | // console.log(error); 22 | return Promise.reject(error); 23 | }); 24 | 25 | instance.interceptors.response.use((response: AxiosResponse): AxiosResponse => { 26 | // You can customize the response here like decrypting data coming from the server 27 | 28 | return response; 29 | }, (error: any): Promise => { 30 | // console.log(error); 31 | const errorString: string = parseHTTPResponse(error.response); 32 | 33 | return Promise.reject(errorString); 34 | }); 35 | 36 | export default instance; 37 | -------------------------------------------------------------------------------- /client/src/utils/http-reponse-parser.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | 3 | import { InvalidDataResponse } from '../types/redux'; 4 | import { capitalize } from './helpers'; 5 | 6 | export const parseHTTPResponse: any = (response: AxiosResponse): string => { 7 | const { data, status }: AxiosResponse = response; 8 | 9 | switch (status) { 10 | case 400: // Bad request 11 | return data.message; 12 | case 401: // Unauthorized 13 | return data.message; 14 | case 403: // Forbidden 15 | return data; 16 | case 404: // Not found 17 | return data.message; 18 | case 422: 19 | const error: InvalidDataResponse = data; 20 | let result: string = ''; 21 | 22 | console.log(error.errors); 23 | for (const property in error.errors) { 24 | result += `${capitalize(property)}: ${error.errors[property].join('; ')} - `; 25 | } 26 | 27 | console.log(result); 28 | 29 | return result; 30 | case 500: // Internal server error 31 | return data; 32 | case 504: // Time out 33 | return data; 34 | 35 | default: 36 | return `Http response error with status code: ${status}`; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /client/src/utils/local-storage-manager.ts: -------------------------------------------------------------------------------- 1 | import { USER_ACCESS_TOKEN_KEY, USER_DATA_KEY, USER_REFRESH_TOKEN_KEY } from './constants'; 2 | import { User } from '../types/model'; 3 | 4 | /** 5 | * @class 6 | */ 7 | class LocalStorageManager { 8 | /** 9 | * Set a value in the cookie 10 | * 11 | * @param {string} cookieName 12 | * @param {string} cookieValue 13 | * @param {number} expireDays 14 | * 15 | * @return void 16 | */ 17 | public static setCookie(cookieName: string, cookieValue: string, expireDays: number): void { 18 | const d: Date = new Date(); 19 | 20 | d.setTime(d.getTime() + (expireDays * 24 * 60 * 60 * 1000)); 21 | const expires: string = `expires=${d.toUTCString()}`; 22 | 23 | document.cookie = `${cookieName}=${cookieValue};${expires};path=/`; 24 | } 25 | 26 | /** 27 | * Get a cookie 28 | * 29 | * @param {string} cookieName 30 | * 31 | * @return string|null 32 | */ 33 | public static getCookie(cookieName: string): string|null { 34 | const name: string = `${cookieName}=`; 35 | const ca: string[] = document.cookie.split(';'); 36 | 37 | for (let i: number = 0; i < ca.length; i += 1) { 38 | let c: string = ca[i]; 39 | 40 | while (c.charAt(0) === ' ') { 41 | c = c.substring(1); 42 | } 43 | if (c.indexOf(name) === 0) { 44 | return c.substring(name.length, c.length); 45 | } 46 | } 47 | 48 | return null; 49 | } 50 | 51 | /*** 52 | * Save the access token of the in the cookie 53 | * 54 | * @param {string} token 55 | * @param {number} expiresIn Expiration date of the in days 56 | * 57 | * @return @void 58 | */ 59 | public static saveUserAccessToken(token: string, expiresIn: number = 1): void { 60 | LocalStorageManager.setCookie(USER_ACCESS_TOKEN_KEY, token, expiresIn); 61 | } 62 | 63 | /*** 64 | * Get the access token of user 65 | * 66 | * @return string|null 67 | */ 68 | public static getUserAccessToken(): string|null { 69 | return LocalStorageManager.getCookie(USER_ACCESS_TOKEN_KEY); 70 | } 71 | 72 | /*** 73 | * Save the refresh token of the in the local storage 74 | * 75 | * @param {string} token 76 | * 77 | * @return @void 78 | */ 79 | public static saveUserRefreshToken(token: string): void { 80 | localStorage.setItem(USER_REFRESH_TOKEN_KEY, token); 81 | } 82 | 83 | /*** 84 | * Get the refresh token of user 85 | * 86 | * @return string|null 87 | */ 88 | public static getUserRefreshToken(): string|null { 89 | return localStorage.getItem(USER_REFRESH_TOKEN_KEY); 90 | } 91 | 92 | /*** 93 | * Save user's information in the local storage 94 | * 95 | * @param {Object} data 96 | * 97 | * @return @void 98 | */ 99 | public static saveUserInfo(data: Object): void { 100 | localStorage.setItem(USER_DATA_KEY, JSON.stringify(data)); 101 | } 102 | 103 | /*** 104 | * Get user's information in the local storage 105 | * 106 | * @return Object|null 107 | */ 108 | public static getUserInfo(): User|null { 109 | const user: string|null = localStorage.getItem(USER_DATA_KEY); 110 | 111 | try { 112 | if (user) { 113 | return JSON.parse(user); 114 | } 115 | } catch (e) { 116 | console.log('User Data Parsing Error => ', e); 117 | } 118 | 119 | return null; 120 | } 121 | 122 | /*** 123 | * Delete all information about the user in the local storage 124 | * 125 | * @return void 126 | */ 127 | public static logoutUser(): void { 128 | localStorage.removeItem(USER_DATA_KEY); 129 | localStorage.removeItem(USER_REFRESH_TOKEN_KEY); 130 | // TODO delete cookie 131 | } 132 | } 133 | 134 | export default LocalStorageManager; 135 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | container_name: mern-starter 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | volumes: 10 | - ./public:/home/www/public 11 | env_file: ./server.env 12 | ports: 13 | - "7430:7430" 14 | links: 15 | - mongodb 16 | networks: 17 | - mernnetwork 18 | mongodb: 19 | container_name: mern-starter-db 20 | image: mongo 21 | volumes: 22 | - ${ENV_FOLDER}/db:/data/db 23 | ports: 24 | - "27000:27017" 25 | env_file: ./mongo.env 26 | networks: 27 | - mernnetwork 28 | networks: 29 | mernnetwork: 30 | driver: bridge 31 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker-compose -f ${ENV_FOLDER}/docker-compose.yml down 4 | 5 | docker-compose -f ${ENV_FOLDER}/docker-compose.yml up -d --build 6 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | BASE_URL=http://127.0.0.1 3 | SERVER_PORT=7430 4 | 5 | APP_NAME=Node React Starter 6 | APP_VERSION=1.1 7 | 8 | API_BASE=/v1/ 9 | DEFAULT_TIMEZONE=Europe/Prague 10 | 11 | AUTH_ENABLED=true 12 | 13 | JWT_SECRET=UMbEJrHSNF$aZc50uRP9B1kz 14 | JWT_EXPIRE=86400 15 | JWT_EMAIL_SECRET=xIT8Vq3Yh$tuDva4I2FxegDo 16 | JWT_EMAIL_EXPIRE=600 17 | JWT_REFRESH_SECRET=rWJ2WRT4F5!5NUzzwPwnsZXy 18 | JWT_REFRESH_EXPIRE=604800 19 | 20 | DB_HOST=127.0.0.1 21 | DB_PORT=27017 22 | DB_NAME= 23 | DB_USER= 24 | DB_PASSWORD= 25 | DB_AUTH=true 26 | 27 | LOG_FILE_DIR=../../log 28 | 29 | MAIL_USERNAME= 30 | MAIL_PASSWORD= 31 | MAIL_HOST= 32 | MAIL_PORT= 33 | 34 | REDIS_HOST=localhost 35 | REDIS_PORT=6379 36 | 37 | WEB_APP_URL=http://localhost:7010 38 | RESET_PASSWORD_PATH=password/reset 39 | CONFIRM_ACCOUNT_PATH=account/confirm 40 | 41 | COUNTRY_REST_BASE_URL=https://restcountries.eu/rest/v2 42 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .idea 64 | .vscode 65 | log 66 | uploads 67 | build 68 | client 69 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # React Node Starter 2 | This is the server part built with Node, Express and MongoDB for data storage 3 | 4 | ## Prerequisites 5 | - Node.js 6 | - MongoDB 7 | - Redis 8 | 9 | ## Installation 10 | - Install dependencies 11 | ```bash 12 | $ cd server 13 | $ yarn 14 | ``` 15 | - Create the configuration file and update with your local config 16 | ```bash 17 | $ cp .env.example .env 18 | $ nano .env 19 | ``` 20 | - Start Application 21 | ```bash 22 | $ yarn start 23 | ``` 24 | The application will be launched by [Nodemon](https://nodemon.com) so it's will restart automatically when a file changed 25 | 26 | ## Documentation 27 | [ RESTful API Modeling Language (RAML)](https://raml.org/) is used to design our API documentation 28 | An editor is provide to write our specification after we use a command to generate the documentation 29 | - Launch the Editor 30 | ```bash 31 | $ yarn api-designer 32 | ``` 33 | Open the browser and navigate to http://localhost:4000 34 | 35 | - Import API specification
36 | The documentation for the available endpoints have already wrote. 37 | We just have to continue by adding our own. For that, you need to:
38 | 1- Zip the content of the folder `public/apidoc`
39 | 2- Import the zip in the API designer 40 | 3- Add or edit specification 41 | 42 | - Generate API Documentation 43 | ```bash 44 | $ yarn apidoc 45 | ``` 46 | Open the browser and navigate to http://localhost:7010/api/documentation 47 | 48 | ## Internationalization 49 | The API can send response in the based on the language of the client. 50 | For that, you need to set the language in the header of the request 51 | ````json 52 | { "Accept-Header": "fr"} 53 | ```` 54 | API will respond in french. Only french and english are available but it's easy to add another language 55 | 56 | ## Test 57 | Mocha and Chai is used to write unit test. 58 | ```bash 59 | $ yarn test 60 | ``` 61 | -------------------------------------------------------------------------------- /server/app/core/config/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import * as path from 'path'; 3 | 4 | dotenv.config(); 5 | 6 | let configPath: string; 7 | 8 | switch (process.env.NODE_ENV) { 9 | case 'test': 10 | configPath = path.resolve(__dirname, '../../.env.test'); 11 | break; 12 | case 'production': 13 | configPath = path.resolve(__dirname, '../../.env.prod'); 14 | break; 15 | default: 16 | configPath = path.resolve(__dirname, '../../.env'); 17 | } 18 | 19 | dotenv.config({ path: configPath }); 20 | 21 | const e: any = process.env; 22 | 23 | const ENV: string = e.NODE_ENV || ''; 24 | const BASE_URL: string = e.BASE_URL || ''; 25 | const SERVER_PORT: number = parseInt(e.SERVER_PORT || '7010', 10); 26 | const APP_NAME: string = e.APP_NAME || ''; 27 | const APP_VERSION : string = e.APP_VERSION || ''; 28 | const API_BASE: string = e.API_BASE || ''; 29 | const DEFAULT_TIMEZONE: string = e.DEFAULT_TIMEZONE || ''; 30 | const AUTH_ENABLED: string = e.AUTH_ENABLED || 'true'; 31 | const JWT_SECRET: string = e.JWT_SECRET || 'UMbEJrHSNF$aZc50uRP9B1kz'; 32 | const JWT_EXPIRE: string = e.JWT_EXPIRE || '86400'; 33 | const JWT_EMAIL_SECRET: string = e.JWT_EMAIL_SECRET || 'xIT8Vq3Yh$tuDva4I2FxegDo'; 34 | const JWT_EMAIL_EXPIRE: string = e.JWT_EMAIL_EXPIRE || '300'; 35 | const JWT_REFRESH_SECRET: string = e.JWT_REFRESH_SECRET || 'rWJ2WRT4F5!5NUzzwPwnsZXy'; 36 | const JWT_REFRESH_EXPIRE: string = e.JWT_REFRESH_EXPIRE || '608400'; 37 | const DB_URL: string = e.DB_URL || ''; 38 | const LOG_FILE_DIR: string = e.LOG_FILE_DIR || ''; 39 | const MAIL_USERNAME: string = e.MAIL_USERNAME || ''; 40 | const MAIL_PASSWORD: string = e.MAIL_PASSWORD || ''; 41 | const MAIL_HOST: string = e.MAIL_HOST || ''; 42 | const MAIL_PORT: string = e.MAIL_PORT || ''; 43 | const WEB_APP_URL: string = e.WEB_APP_URL || ''; 44 | const RESET_PASSWORD_PATH: string = e.RESET_PASSWORD_PATH || ''; 45 | const CONFIRM_ACCOUNT_PATH: string = e.CONFIRM_ACCOUNT_PATH || ''; 46 | const COUNTRY_REST_BASE_URL: string = e.COUNTRY_REST_BASE_URL || ''; 47 | const REDIS_HOST: string = e.REDIS_HOST || 'localhost'; 48 | const REDIS_PORT: number = parseInt(e.REDIS_PORT || '6379', 10); 49 | 50 | export { 51 | ENV, BASE_URL, SERVER_PORT, APP_NAME, APP_VERSION, API_BASE, DEFAULT_TIMEZONE, AUTH_ENABLED, JWT_SECRET, JWT_EXPIRE, 52 | JWT_EMAIL_SECRET, JWT_EMAIL_EXPIRE, JWT_REFRESH_SECRET, JWT_REFRESH_EXPIRE, DB_URL, LOG_FILE_DIR, MAIL_USERNAME, 53 | MAIL_PASSWORD, MAIL_HOST, MAIL_PORT, WEB_APP_URL, RESET_PASSWORD_PATH, CONFIRM_ACCOUNT_PATH, COUNTRY_REST_BASE_URL, 54 | REDIS_HOST, REDIS_PORT, 55 | }; 56 | -------------------------------------------------------------------------------- /server/app/core/db/connect.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { ConnectOptions } from 'mongoose'; 2 | 3 | import * as config from '../config'; 4 | 5 | import { logger } from '../logger'; 6 | import { DB_CONNECTION_SUCCESS } from '../../utils/constants'; 7 | 8 | mongoose.Promise = global.Promise; 9 | 10 | /** 11 | * Create the connection to the database 12 | * @async 13 | * 14 | * @return Promise 15 | */ 16 | const dbConnection = async (): Promise => { 17 | try { 18 | await mongoose.connect(config.DB_URL); 19 | 20 | logger.info(DB_CONNECTION_SUCCESS); 21 | } catch (err) { 22 | logger.error(err); 23 | } 24 | }; 25 | 26 | export { dbConnection }; 27 | -------------------------------------------------------------------------------- /server/app/core/logger/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import * as fs from 'fs'; 3 | import { createLogger, format, transports, Logger } from 'winston'; 4 | import { isObject } from 'lodash'; 5 | 6 | import * as config from '../config'; 7 | 8 | type EnhancedLogger = { 9 | error: (output: unknown, logToSentry?: boolean) => void; 10 | info: (output: unknown) => void; 11 | }; 12 | 13 | const { combine, printf, timestamp }: typeof format = format; 14 | // eslint-disable-next-line @typescript-eslint/no-var-requires 15 | const t: any = require('winston-daily-rotate-file'); 16 | 17 | const logFileDir: string = path.join(__dirname, config.LOG_FILE_DIR); 18 | 19 | if (!fs.existsSync(logFileDir)) { 20 | fs.mkdirSync(logFileDir); 21 | } 22 | const transport: any = new (t)({ 23 | dirname: logFileDir, 24 | filename: 'logs/app-%DATE%.log', 25 | datePattern: 'YYYY-MM-DD-HH', 26 | zippedArchive: true, 27 | maxSize: '20m', 28 | maxFiles: '14d', 29 | }); 30 | 31 | const logMessage = (message: any): string => { 32 | // @ts-ignore 33 | return isObject(message) ? (message.stack ? message.stack : JSON.stringify(message, null, 2)) : message.toString(); 34 | }; 35 | 36 | const myFormat = printf((info) => { 37 | const { level, message, timestamp } = info; 38 | 39 | return `${timestamp} ${level}: ${logMessage(message)}`; 40 | }); 41 | 42 | const winstonLogger: Logger = createLogger({ 43 | format: combine(timestamp(), myFormat), 44 | silent: config.ENV === 'test', 45 | transports: [transport, new transports.Console()], 46 | }); 47 | 48 | const logger: EnhancedLogger = { 49 | error: (error: unknown) => { 50 | console.log(error); 51 | 52 | winstonLogger.error(logMessage(error)); 53 | }, 54 | info: (output: unknown) => winstonLogger.info(logMessage(output)), 55 | }; 56 | 57 | export { logger }; 58 | 59 | -------------------------------------------------------------------------------- /server/app/core/mailer/index.ts: -------------------------------------------------------------------------------- 1 | import * as nodemailer from 'nodemailer'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import handlebars from 'handlebars'; 5 | import Mail from 'nodemailer/lib/mailer'; 6 | 7 | import * as config from '../config'; 8 | import { logger } from '../logger'; 9 | import { Locale } from '../locale'; 10 | 11 | /** 12 | * This class is responsible to send email with HTML template 13 | * 14 | * @class 15 | */ 16 | class Mailer { 17 | /** 18 | * Set a value in Redis 19 | * @static 20 | * 21 | * @param {Object} data 22 | * 23 | * @return void 24 | */ 25 | static sendMail(data: any): void { 26 | try { 27 | const user: string = config.MAIL_USERNAME; 28 | 29 | const smtpTransport: Mail = nodemailer.createTransport({ 30 | // @ts-ignore 31 | host: config.MAIL_HOST, 32 | port: config.MAIL_PORT, 33 | auth: { 34 | user, 35 | pass: config.MAIL_PASSWORD, 36 | }, 37 | }); 38 | 39 | const filePath: string = `${path.join(__dirname, `./templates/${Locale.getLocale()}`)}/${data.template}.html`; 40 | const source: Buffer = fs.readFileSync(filePath); 41 | const template: HandlebarsTemplateDelegate = handlebars.compile(source.toString()); 42 | const html: string = template(data.context); 43 | 44 | const updatedData: any = { 45 | ...data, 46 | html, 47 | from: `Node Starter <${user}>`, 48 | subject: Locale.trans(data.subject), 49 | }; 50 | 51 | smtpTransport.sendMail(updatedData).then((result: nodemailer.SentMessageInfo): void => { 52 | logger.info(result.toString()); 53 | }); 54 | } catch (e) { 55 | logger.error(e); 56 | } 57 | } 58 | } 59 | 60 | export default Mailer; 61 | -------------------------------------------------------------------------------- /server/app/core/mailer/templates/en/confirm-account-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Confirm Account Email 6 | 7 | 8 | 9 |
10 |

Confirm your account

11 |

Welcome to Boily {{name}} !

12 |

13 | Your account has been created on our platform; here is your login information 14 |

15 | Email address : {{email}}
16 |
17 | To confirm the validity of your email address, please click on the link below 18 |

19 |
20 |

21 | 22 | Confirm my account 23 | 24 |

25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/app/core/mailer/templates/en/forgot-password-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Forget Password Email 6 | 7 | 8 | 9 |
10 |

Dear {{name}},

11 |

You requested for a password reset, kindly use this link to reset your password

12 |
13 |

Best regards!

14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /server/app/core/mailer/templates/en/reset-password-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Password Reset 6 | 7 | 8 | 9 |
10 |

Dear {{name}},

11 |

Your password has been successful reset, you can now login with your new password.

12 |
13 |
14 | Best regards! 15 |
16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /server/app/core/mailer/templates/fr/confirm-account-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Email de confirmation du compte 6 | 7 | 8 | 9 |
10 |

Confirmez votre compte

11 |

Bienvenue sur Boily {{name}} !

12 |

13 | Votre compte a été créé sur notre plateforme. Voici vos informations de connexion 14 |

15 | Addresse email : {{email}}
16 |
17 | Pour confirmer la validité de votre adresse e-mail, veuillez cliquer sur le lien ci-dessous. 18 |

19 |
20 |

21 | 22 | Confirmer mon compte 23 | 24 |

25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/app/core/mailer/templates/fr/forgot-password-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Email de reinitialisation de mot de passe 6 | 7 | 8 | 9 |
10 |

Cher {{name}},

11 |

12 | Vous avez demandé une réinitialisation du mot de passe, merci de l’utiliser 13 | Reinitialiser mon mot de passe pour réinitialiser votre mot de passe 14 |

15 |
16 |

Cordialement

17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /server/app/core/mailer/templates/fr/reset-password-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Reinitialisation du mot de passe réussi 6 | 7 | 8 | 9 |
10 |

Cher {{name}},

11 |

12 | Votre mot de passe a été réinitialisé avec succès, vous pouvez maintenant 13 | vous connecter avec votre nouveau mot de passe. 14 |

15 |
16 |
17 | Cordialement 18 |
19 |
20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /server/app/core/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from 'jsonwebtoken'; 2 | import { Response, NextFunction } from 'express'; 3 | 4 | import * as config from '../config'; 5 | import { logger } from '../logger'; 6 | 7 | import { Locale } from '../locale'; 8 | import { CustomRequest } from '../types'; 9 | 10 | export type CreateJWTTokenFunction = (payload: any, jwtSecret: string, jwtExpire: number) => string; 11 | 12 | /** 13 | * Create a JWT Token 14 | * 15 | * @param {any} payload Information to encode 16 | * @param {string} jwtSecret JWT Secret 17 | * @param {number} jwtExpire JWT Expiration date 18 | * 19 | * @return string 20 | */ 21 | export const createJwtToken: CreateJWTTokenFunction = (payload: any, jwtSecret: string, jwtExpire: number): string => { 22 | return jwt.sign(payload, jwtSecret, { expiresIn: jwtExpire }); 23 | }; 24 | 25 | /** 26 | * Create a new user and save to the database 27 | * After registered, a confirmation's email is sent 28 | * 29 | * @param {string} token: Token to decode 30 | * @param {string} jwtSecret: Secret key used to create the token 31 | * 32 | * @return Promise 33 | */ 34 | export const decodeJwtToken : Function = (token: string, jwtSecret: string): Promise => { 35 | return new Promise((resolve: Function, reject: Function): void => { 36 | try { 37 | const decoded = jwt.verify(token, jwtSecret); 38 | 39 | return resolve(decoded) 40 | } catch(err) { 41 | return reject(err); 42 | } 43 | }); 44 | }; 45 | 46 | /** 47 | * Middleware to authorize a request only if a valid token is provided 48 | * 49 | * @param {Request|any} req: Request object 50 | * @param {Response} res: Response object 51 | * @param {NextFunction} next: NextFunction object 52 | * 53 | * @return any 54 | */ 55 | const authMiddleware: any = async (req: CustomRequest|any, res: Response, next: NextFunction): Promise => { 56 | const token: any = req.headers['x-access-token']; 57 | 58 | const allowedRoutes: string[] = [ 59 | '/', 60 | '/api/documentation', 61 | 'auth/login', 62 | 'auth/register', 63 | 'auth/account/confirm', 64 | 'auth/password/forgot', 65 | 'auth/password/reset', 66 | 'token/refresh', 67 | ]; 68 | 69 | // let routeName = null; 70 | 71 | if (req.originalUrl) { 72 | if (req.originalUrl.includes(config.API_BASE)) { 73 | const routeName: string = req.originalUrl.replace(config.API_BASE || '', ''); 74 | 75 | if (allowedRoutes.includes(routeName)) { 76 | return next(); 77 | } 78 | 79 | if (token) { 80 | try { 81 | const decoded: any = await decodeJwtToken(token, config.JWT_SECRET); 82 | 83 | if (!decoded.id) { 84 | return res.status(401).json({ message: Locale.trans('unauthorized') }); 85 | } 86 | 87 | // if everything good, save to request for use in other routes 88 | req.userId = decoded.id; 89 | 90 | return next(); 91 | } catch (err) { 92 | logger.error(err); 93 | } 94 | } 95 | 96 | return res.status(401).json({ message: Locale.trans('unauthorized') }); 97 | } 98 | 99 | return next(); 100 | } 101 | 102 | return res.status(401).json({ message: Locale.trans('unauthorized') }); 103 | }; 104 | 105 | export { authMiddleware }; 106 | -------------------------------------------------------------------------------- /server/app/core/middleware/locale.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | import { Locale } from '../locale'; 4 | 5 | /** 6 | * Middleware to get the language of the client 7 | * @async 8 | * 9 | * @param {Request|any} req: Request object 10 | * @param {Response} res: Response object 11 | * @param {NextFunction} next: NextFunction object 12 | * 13 | * @return Promise 14 | */ 15 | const localeMiddleware: any = async (req: Request, res: Response, next: NextFunction): Promise => { 16 | const availableLocales: string[] = Locale.getAvailableLocales(); 17 | const language: string = req.headers['accept-language'] || availableLocales[0]; 18 | 19 | Locale.setLocale(availableLocales.indexOf(language) >= 0 ? language : availableLocales[0]); 20 | 21 | return next(); 22 | }; 23 | 24 | export { localeMiddleware }; 25 | -------------------------------------------------------------------------------- /server/app/core/storage/redis-manager.ts: -------------------------------------------------------------------------------- 1 | import {createClient, RedisClientType} from 'redis'; 2 | 3 | import { REDIS_HOST, REDIS_PORT } from '../config'; 4 | 5 | class RedisManager { 6 | private static client: RedisClientType; 7 | 8 | public static async init() { 9 | if (!RedisManager.client) { 10 | RedisManager.client = createClient({ url: `redis://${REDIS_HOST}:${REDIS_PORT}` }); 11 | 12 | await RedisManager.client.connect(); 13 | } 14 | } 15 | 16 | public static get(key: string): Promise { 17 | return RedisManager.client.get(key); 18 | } 19 | 20 | public static async set(key: string, value: string, expire: number = -1): Promise { 21 | if (expire > 0) { 22 | return await RedisManager.setWithExpire(key, value, expire); 23 | } 24 | 25 | return await RedisManager.setWithoutExpire(key, value); 26 | } 27 | 28 | private static setWithExpire(key: string, value: string, expire: number): Promise { 29 | return RedisManager.client.setEx(key, expire, value); 30 | } 31 | 32 | private static setWithoutExpire(key: string, value: string): Promise { 33 | return RedisManager.client.set(key, value); 34 | } 35 | 36 | public static delete(key: string): Promise { 37 | return RedisManager.client.del(key); 38 | } 39 | 40 | public static keys(pattern: string): Promise { 41 | return RedisManager.client.keys(pattern); 42 | } 43 | 44 | public static getValues(keys: string[]): Promise> { 45 | return RedisManager.client.mGet(keys); 46 | } 47 | } 48 | 49 | export { RedisManager }; 50 | -------------------------------------------------------------------------------- /server/app/core/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Application } from 'express'; 2 | 3 | export interface CustomRequest extends Request { 4 | userId: number; 5 | } 6 | 7 | export type TokenInfo = { 8 | id: string; 9 | }; 10 | 11 | export type Locales = { 12 | [string: string]: { 13 | [string: string]: string, 14 | }, 15 | }; 16 | 17 | export interface IServer { 18 | app: Application; 19 | } 20 | 21 | export type ValidatorMethod = { 22 | [key: string]: { 23 | [key: string]: string, 24 | }, 25 | }; 26 | 27 | export type RegexObject = { 28 | ipAddress: RegExp; 29 | url: RegExp; 30 | email: RegExp; 31 | date: RegExp; 32 | }; 33 | 34 | export type InternalServerError = { 35 | message: string; 36 | }; 37 | -------------------------------------------------------------------------------- /server/app/core/types/models.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document } from 'mongoose'; 2 | 3 | export interface IModel extends Document { 4 | _id: Schema.Types.ObjectId; 5 | created_at?: string; 6 | updated_at?: string; 7 | } 8 | 9 | export interface IUserModel extends Document { 10 | name: string; 11 | username: string; 12 | email: string; 13 | password: string; 14 | gender: string; 15 | confirmed: boolean; 16 | email_token?: string; 17 | avatar?: string; 18 | } 19 | 20 | export interface ITaskModel { 21 | _id?: string; 22 | id?: string; 23 | title: string; 24 | description: string; 25 | date: string; 26 | status: string; 27 | is_important: boolean; 28 | user: string; 29 | } 30 | -------------------------------------------------------------------------------- /server/app/core/types/socket.ts: -------------------------------------------------------------------------------- 1 | import socketIo from 'socket.io'; 2 | 3 | export type SocketSessionItem = { 4 | socket: socketIo.Socket, 5 | }; 6 | 7 | export type SocketSession = { 8 | [key: string]: SocketSessionItem, 9 | }; 10 | -------------------------------------------------------------------------------- /server/app/index.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import express, { Application } from 'express'; 3 | import * as config from './core/config'; 4 | 5 | import { Routes } from './routes'; 6 | import { SocketManager } from './socket'; 7 | import { Locale } from './core/locale'; 8 | import { logger } from './core/logger'; 9 | import { dbConnection } from './core/db/connect'; 10 | import {RedisManager} from './core/storage/redis-manager'; 11 | 12 | const port: number = config.SERVER_PORT; 13 | 14 | const app: Application = express(); 15 | 16 | // Cron.init(); 17 | Routes.init(app); 18 | 19 | const server: http.Server = http.createServer(app); 20 | 21 | SocketManager.init(server); 22 | 23 | server.listen(port, async() => { 24 | await dbConnection(); 25 | 26 | await RedisManager.init(); 27 | 28 | Locale.init(); 29 | 30 | logger.info(`Server started - ${port}`); 31 | }); 32 | 33 | export default server; 34 | -------------------------------------------------------------------------------- /server/app/locale/en/message.json: -------------------------------------------------------------------------------- 1 | { 2 | "server.start": "Application started at port: {port}", 3 | "db.synced": "DB Synchronized !", 4 | "country.loaded": "Countries initialized successfully!", 5 | "unauthorized": "Access Unauthorized due to invalid authorization key.", 6 | "unauthorized.resource": "Access Unauthorized to this resource.", 7 | "endpoint.not.found": "Endpoint not found !", 8 | "welcome": "Welcome to Node Rest API !", 9 | "db.connected": "Connected to database !", 10 | "internal.error": "Internal error occurred!", 11 | "email.exist": "The email address already exist !", 12 | "model.deleted": "{model} deleted successfully !", 13 | "model.not.found": "The {model} not found in the database!", 14 | "model.exist": "The {model} already exist!", 15 | "login.failed": "Email or password is incorrect", 16 | "invalid.password": "Your current password is not correct!", 17 | "no.token": "No token provided!", 18 | "auth.token.failed": "Failed to authenticate token.", 19 | "no.user": "No user found", 20 | "password.reset": "password has been reset.", 21 | "token.expired": "The token has been expired", 22 | "email.failed": "Error occurs when sending mail.", 23 | "email.success": "Kindly check your email for further instructions", 24 | "bad.token": "The token is invalid!", 25 | "account.confirmed": "Account confirmed successfully!", 26 | "file.error": "File load error!", 27 | "account.unconfirmed": "Your account is not confirmed yet!", 28 | "social.id.required": "You need to provide either facebook_guid or google_guid!", 29 | "not.valid.picture": "Invalid file type. Only picture file is allowed !", 30 | "unexpected.error": "Unexpected error: {error}", 31 | "large.file": "File size too large !", 32 | "one.settings.error": "'You have only one settings ! You can't delete it!", 33 | "model.saved": "The {model} saved successfully!", 34 | "register.success": "User registered successfully!", 35 | "mail.subject.confirm.account": "Confirm account", 36 | "mail.subject.forgot.password": "You have forgotten your password" 37 | } 38 | -------------------------------------------------------------------------------- /server/app/locale/en/validation.json: -------------------------------------------------------------------------------- 1 | { 2 | "input.required": "The input is required", 3 | "input.empty": "The input may not be empty", 4 | "email.invalid": "The email is invalid", 5 | "input.taken": "The value is already taken", 6 | "min.length": "The value must be {value}+ chars long", 7 | "password.match": "The current password doesn't match", 8 | "max.length": "The value can't have more than {value} char(s)", 9 | "input.date.invalid": "The date format is invalid ! Must be YYYY-MM-DD hh:mm:ss", 10 | "user.not.exist": "No user found with the ID provided" 11 | } 12 | -------------------------------------------------------------------------------- /server/app/locale/fr/message.json: -------------------------------------------------------------------------------- 1 | { 2 | "server.start": "L'application a démarré sur le port : {port}", 3 | "db.synced": "BD Synchronizée !", 4 | "country.loaded": "Les pays ont été initialisés avec succès!", 5 | "unauthorized": "Accès non autorisé en raison d'une clé d'autorisation non valide.", 6 | "unauthorized.resource": "Accès non autorisé à cette ressource.", 7 | "endpoint.not.found": "Endpoint non trouvé !", 8 | "welcome": "Bienvenue sur Node Rest API !", 9 | "db.connected": "Connecté à la base de données !", 10 | "internal.error": "Une erreur interne s'est produite!", 11 | "email.exist": "L'adresse email existe déjà!", 12 | "model.deleted": "{model} supprimé avec succès !", 13 | "model.not.found": "Le {model} non trouvé dans la base de données!", 14 | "model.exist": "Le {model} existe déjà !", 15 | "login.failed": "E-mail ou mot de passe incorrect", 16 | "invalid.password": "Votre mot de passe actuel n'est pas correct!", 17 | "no.token": "Aucun jeton fourni!", 18 | "auth.token.failed": "Échec de l'authentification du jeton.", 19 | "no.user": "Aucun utilisateur trouvé", 20 | "password.reset": "Le mot de passe a été réinitialisé.", 21 | "token.expired": "Le jeton a expiré", 22 | "email.failed": "Une erreur s'est produite lors de l'envoi du courrier.", 23 | "email.success": "Veuillez vérifier votre email pour plus d'instructions", 24 | "bad.token": "Le jeton est invalide!", 25 | "account.confirmed": "Compte confirmé avec succès!", 26 | "file.error": "Erreur de chargement de fichier!", 27 | "account.unconfirmed": "Votre compte n'est pas encore confirmé!", 28 | "social.id.required": "Vous devez fournir soit facebook_guid, soit google_guid!", 29 | "not.valid.picture": "Type de fichier invalide. Seul le fichier image est autorisé!", 30 | "unexpected.error": "Erreur inattendue: {error}", 31 | "large.file": "La taille du fichier est trop grande !", 32 | "one.settings.error": "Vous avez un seul paramètre, impossible de le supprimer !", 33 | "model.saved": "Le {model} a été sauvegardé avec succès!", 34 | "register.success": "Utilisateur enregistré avec succès !", 35 | "mail.subject.confirm.account": "Confirmation du compte", 36 | "mail.subject.forgot.password": "Vous avez oublié votre mot de passe", 37 | "current.password.invalid": "The current password is incorrect !" 38 | } 39 | -------------------------------------------------------------------------------- /server/app/locale/fr/validation.json: -------------------------------------------------------------------------------- 1 | { 2 | "input.required": "La valeur est obligatoire", 3 | "input.empty": "La valeur ne peut pas être vide", 4 | "email.invalid": "L'adresse email est invalide", 5 | "input.taken": "La valeur est déjà prise", 6 | "min.length": "La valeur doit avoir {valeur} caractère(s) ou plus", 7 | "password.match": "Le mot de passe actuel ne correspond pas", 8 | "max.length": "La valeur ne peut pas avoir plus de {value} caractère (s)", 9 | "input.date.invalid": "Le format de date est invalide ! Doit être YYYY-MM-DD hh:mm:ss", 10 | "user.not.exist": "Aucun utilisateur trouvé avec l'ID fourni" 11 | } 12 | -------------------------------------------------------------------------------- /server/app/models/task.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model, Schema } from 'mongoose'; 2 | 3 | // tslint:disable-next-line:variable-name 4 | const TaskSchema: Schema = new Schema({ 5 | title: { 6 | type: String, 7 | required: true, 8 | }, 9 | description: { 10 | type: String, 11 | required: true, 12 | }, 13 | date: { 14 | type: Date, 15 | required: true, 16 | }, 17 | status: { 18 | type: String, 19 | enum: ['Pending', 'Working', 'Done'], 20 | required: true, 21 | default: 'Pending', 22 | }, 23 | is_important: { 24 | type: Boolean, 25 | required: true, 26 | default: false, 27 | }, 28 | user: { 29 | type: Schema.Types.ObjectId, 30 | ref: 'User', 31 | required: true, 32 | }, 33 | }, { 34 | timestamps: { 35 | createdAt: 'created_at', 36 | updatedAt: 'updated_at', 37 | }, 38 | collection: 'tasks', 39 | }); 40 | 41 | // tslint:disable-next-line:variable-name 42 | const TaskModel: Model = mongoose.model('Task', TaskSchema); 43 | 44 | const taskUpdateParams: string[] = [ 45 | 'title', 46 | 'description', 47 | 'date', 48 | 'status', 49 | 'is_important', 50 | ]; 51 | 52 | export { TaskModel, taskUpdateParams }; 53 | -------------------------------------------------------------------------------- /server/app/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model as MongooseModel } from 'mongoose'; 2 | 3 | const userSchema: mongoose.Schema = new mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true, 7 | }, 8 | username: { 9 | type: String, 10 | required: true, 11 | unique: true, 12 | }, 13 | email: { 14 | type: String, 15 | required: true, 16 | unique: true, 17 | }, 18 | password: { 19 | type: String, 20 | required: true, 21 | }, 22 | gender: { 23 | type: String, 24 | required: false, 25 | default: 'M', 26 | }, 27 | confirmed: { 28 | type: Boolean, 29 | required: true, 30 | default: false, 31 | }, 32 | email_token: { 33 | type: String, 34 | required: false, 35 | default: null, 36 | }, 37 | avatar: { 38 | type: String, 39 | required: false, 40 | default: null, 41 | }, 42 | }, { 43 | timestamps: { 44 | createdAt: 'created_at', 45 | updatedAt: 'updated_at', 46 | }, 47 | collection: 'users', 48 | }); 49 | 50 | // tslint:disable-next-line:variable-name 51 | const UserModel: MongooseModel = mongoose.model('User', userSchema); 52 | 53 | const userUpdateParams: string[] = [ 54 | 'name', 55 | 'username', 56 | 'gender', 57 | ]; 58 | 59 | export { UserModel, userUpdateParams }; 60 | -------------------------------------------------------------------------------- /server/app/routes/auth.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { API_BASE } from '../core/config'; 4 | 5 | import userValidator from '../validator/user.validator'; 6 | import { Validator } from '../validator'; 7 | 8 | import { AuthController } from '../controllers/auth.controller'; 9 | 10 | const { user }: any = Validator.methods; 11 | 12 | /** 13 | * Router configuration for authentication 14 | * 15 | * @class 16 | */ 17 | class AuthRouter { 18 | public router: Router; 19 | 20 | constructor() { 21 | this.router = Router(); 22 | this.routes(); 23 | } 24 | 25 | routes(): void { 26 | const prefix: string = `${API_BASE}auth`; 27 | 28 | this.router.post(`${prefix}/register`, userValidator.validate(user.createUser), AuthController.register); 29 | 30 | this.router.post(`${prefix}/account/confirm`, userValidator.validate(user.confirmAccount), AuthController.confirmAccount); 31 | 32 | this.router.post(`${prefix}/login`, userValidator.validate(user.loginUser), AuthController.login); 33 | 34 | this.router.post(`${prefix}/password/forgot`, userValidator.validate(user.forgotPassword), AuthController.forgotPassword); 35 | 36 | this.router.post(`${prefix}/password/reset`, userValidator.validate(user.resetPassword), AuthController.resetPassword); 37 | 38 | this.router.post(`${prefix}/token/refresh`, userValidator.validate(user.refreshToken), AuthController.refreshToken); 39 | } 40 | } 41 | 42 | export { AuthRouter }; 43 | -------------------------------------------------------------------------------- /server/app/routes/default.route.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import * as path from 'path'; 3 | 4 | /** 5 | * Router configuration for common route 6 | * 7 | * @class 8 | */ 9 | class DefaultRouter { 10 | public router: Router; 11 | 12 | constructor() { 13 | this.router = Router(); 14 | this.routes(); 15 | } 16 | 17 | routes(): void { 18 | this.router.get('/api/documentation', (req: Request, res: Response) => { 19 | res.sendFile(path.join(__dirname, '../../public/apidoc/index.html')); 20 | }); 21 | 22 | this.router.get('/', (req: Request, res: Response) => { 23 | if (process.env.NODE_ENV === 'production') { 24 | return res.sendFile(path.resolve(__dirname, '../../client', 'index.html')); 25 | } 26 | 27 | return res.json({ message: 'React Starter API' }); 28 | }); 29 | } 30 | } 31 | 32 | export { DefaultRouter }; 33 | -------------------------------------------------------------------------------- /server/app/routes/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as bodyParser from 'body-parser'; 3 | import cookieParser from 'cookie-parser'; 4 | import cors from 'cors'; 5 | import helmet from 'helmet'; 6 | import * as path from 'path'; 7 | 8 | import * as config from '../core/config'; 9 | 10 | import { DefaultRouter } from './default.route'; 11 | import { AuthRouter } from './auth.route'; 12 | import { UserRouter } from './user.route'; 13 | import { TaskRouter } from './task.route'; 14 | 15 | import { localeMiddleware } from '../core/middleware/locale'; 16 | import { authMiddleware } from '../core/middleware/auth'; 17 | 18 | /** 19 | * Global router configuration of the application 20 | * 21 | * @class 22 | */ 23 | class Routes { 24 | /** 25 | * @param {Application} app 26 | * 27 | * @returns void 28 | */ 29 | static init(app: express.Application): void { 30 | const router: express.Router = express.Router(); 31 | 32 | // Express middleware 33 | app.use(bodyParser.urlencoded({ extended: true })); 34 | app.use(bodyParser.json()); 35 | // app.use(cookieParser()); 36 | // app.use(helmet()); 37 | // app.use(helmet.noSniff()); 38 | app.use(cors()); 39 | 40 | app.use(localeMiddleware); 41 | 42 | if (config.AUTH_ENABLED === 'true') { 43 | app.use(authMiddleware); 44 | } 45 | 46 | app.use('/', router); 47 | // default 48 | app.use('/', new DefaultRouter().router); 49 | // auth 50 | app.use('/', new AuthRouter().router); 51 | // users 52 | app.use('/', new UserRouter().router); 53 | // tasks 54 | app.use('/', new TaskRouter().router); 55 | 56 | // Static content 57 | app.use(express.static(path.join(__dirname, '../../public'))); 58 | 59 | if (config.ENV === 'production') { 60 | app.use(express.static(path.resolve(__dirname, '../../client'))); 61 | } 62 | } 63 | } 64 | 65 | export { Routes }; 66 | -------------------------------------------------------------------------------- /server/app/routes/task.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { TaskController } from '../controllers/task.controller'; 4 | import { Validator } from '../validator'; 5 | import taskValidator from '../validator/task.validator'; 6 | 7 | import { API_BASE } from '../core/config'; 8 | 9 | const { task }: any = Validator.methods; 10 | 11 | /** 12 | * Router configuration for task 13 | * 14 | * @class 15 | */ 16 | class TaskRouter { 17 | public router: Router; 18 | 19 | constructor() { 20 | this.router = Router(); 21 | this.routes(); 22 | } 23 | 24 | routes(): void { 25 | const prefix = `${API_BASE}tasks`; 26 | 27 | this.router.post(`${prefix}/create`, taskValidator.validate(task.createTask), TaskController.create); 28 | 29 | this.router.put(`${prefix}/:id`, taskValidator.validate(task.updateTask), TaskController.update); 30 | 31 | this.router.delete(`${prefix}/:id`, TaskController.destroy); 32 | 33 | this.router.get(`${prefix}`, TaskController.all); 34 | 35 | this.router.get(`${prefix}/:id`, TaskController.one); 36 | } 37 | } 38 | 39 | export { TaskRouter }; 40 | -------------------------------------------------------------------------------- /server/app/routes/user.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { API_BASE } from '../core/config'; 4 | 5 | import userValidator from '../validator/user.validator'; 6 | import { Validator } from '../validator'; 7 | 8 | import { UserController } from '../controllers/user.controller'; 9 | 10 | const { user }: any = Validator.methods; 11 | 12 | /** 13 | * Router configuration for user 14 | * 15 | * @class 16 | */ 17 | class UserRouter { 18 | public router: Router; 19 | 20 | constructor() { 21 | this.router = Router(); 22 | this.routes(); 23 | } 24 | 25 | routes(): void { 26 | const prefix: string = `${API_BASE}users`; 27 | 28 | this.router.get(`${prefix}/me`, UserController.me); 29 | 30 | this.router.get(`${prefix}`, UserController.all); 31 | 32 | this.router.get(`${prefix}/:id`, UserController.one); 33 | 34 | this.router.put(`${prefix}`, userValidator.validate(user.updateUser), UserController.update); 35 | 36 | this.router.put(`${prefix}/password`, userValidator.validate(user.updateUserPassword), UserController.updatePassword); 37 | 38 | this.router.delete(`${prefix}/:id`, userValidator.validate(user.deleteUser), UserController.destroy); 39 | } 40 | } 41 | 42 | export { UserRouter }; 43 | -------------------------------------------------------------------------------- /server/app/socket/events.ts: -------------------------------------------------------------------------------- 1 | export const SOCKET_REQUEST_EVENT: string = 'react_start_req'; 2 | export const SOCKET_RESPONSE_EVENT: string = 'react_start_res'; 3 | 4 | export enum SocketRequestAction { 5 | GET_COUNTRY_INFO_REQUEST = 'SCK001', 6 | } 7 | -------------------------------------------------------------------------------- /server/app/socket/index.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import socketIo from 'socket.io'; 3 | import { randomStr } from '../utils/helpers'; 4 | import { SocketSession, SocketSessionItem } from '../core/types/socket'; 5 | import { SocketRequestAction, SOCKET_REQUEST_EVENT } from './events'; 6 | 7 | // Socket tasks 8 | import { GetCountryTask } from './tasks/get-country.task'; 9 | import { WEB_APP_URL } from '../core/config'; 10 | 11 | class SocketManager { 12 | static sessions: SocketSession = { }; 13 | 14 | /** 15 | * @param {http.Server} server 16 | * 17 | * @return void 18 | */ 19 | static init(server: http.Server): void { 20 | const io: socketIo.Server = new socketIo.Server(server, { 21 | pingTimeout: 700000, 22 | cors: { 23 | origin: [WEB_APP_URL] 24 | }, 25 | }); 26 | 27 | io.sockets.on('connection', async (socket: socketIo.Socket) => { 28 | const socketSessionId: string = SocketManager.createSession(socket); 29 | 30 | socket.on(SOCKET_REQUEST_EVENT, (dataString: string) => { 31 | const data: any = JSON.parse(dataString); 32 | 33 | console.log('[Received]: %s', dataString); 34 | 35 | switch (data.rqid) { 36 | case SocketRequestAction.GET_COUNTRY_INFO_REQUEST: 37 | GetCountryTask.run(SocketManager.sessions, socketSessionId, data); 38 | break; 39 | default: 40 | console.log('The socket action doesn\'t exist!'); 41 | break; 42 | } 43 | }); 44 | 45 | socket.on('disconnect', () => { 46 | socket.disconnect(true); 47 | SocketManager.deleteSession(socketSessionId); 48 | console.log('Client disconnected'); 49 | }); 50 | }); 51 | } 52 | 53 | /** 54 | * Create a socket's session for the user connected 55 | * 56 | * @param socket The socket instance of the user 57 | * 58 | * @return string The socket's session ID 59 | */ 60 | static createSession(socket: socketIo.Socket): string { 61 | const socketSessionId: string = randomStr(24); 62 | 63 | console.log('socketSessionId', socketSessionId); 64 | 65 | if (!SocketManager.sessions[socketSessionId]) { 66 | SocketManager.sessions = { 67 | ...this.sessions, [socketSessionId]: { socket }, 68 | }; 69 | } 70 | 71 | return socketSessionId; 72 | } 73 | 74 | /** 75 | * Get a socket's session 76 | * 77 | * @param socketSessionId The socket's session ID 78 | * 79 | * @return SocketSessionItem|null The socket's session item associated to the provided ID or null if not found 80 | */ 81 | static getSession(socketSessionId: string): SocketSessionItem | null { 82 | if (!SocketManager.sessions[socketSessionId]) { 83 | return SocketManager.sessions[socketSessionId]; 84 | } 85 | 86 | return null; 87 | } 88 | 89 | /** 90 | * Get a socket's session 91 | * 92 | * @param socketSessionId The socket's session ID to delete 93 | * 94 | * @return void 95 | */ 96 | static deleteSession(socketSessionId: string): void { 97 | if (!SocketManager.sessions[socketSessionId]) { 98 | delete SocketManager.sessions[socketSessionId]; 99 | } 100 | } 101 | } 102 | 103 | export { SocketManager }; 104 | -------------------------------------------------------------------------------- /server/app/socket/tasks/get-country.task.ts: -------------------------------------------------------------------------------- 1 | import joi from 'joi'; 2 | import axios, { AxiosResponse, AxiosError } from 'axios'; 3 | 4 | import { SocketSession, SocketSessionItem } from '../../core/types/socket'; 5 | import { SOCKET_RESPONSE_EVENT } from '../events'; 6 | import { SocketTask } from './task'; 7 | import { COUNTRY_REST_BASE_URL } from '../../core/config'; 8 | 9 | class GetCountryTask { 10 | static schema: joi.ObjectSchema = joi.object().keys({ 11 | code: joi.string().required(), 12 | }); 13 | 14 | static async run(socketSessions: SocketSession, socketSessionId: string, data: any): Promise { 15 | const sessionItem: SocketSessionItem | undefined = socketSessions[socketSessionId]; 16 | 17 | const joiValidation: joi.ValidationResult = SocketTask.validateWithDefaultSchema( 18 | data, 19 | GetCountryTask.schema, 20 | ); 21 | 22 | if (joiValidation.error) { 23 | const response: string = JSON.stringify({ errorType: 'wrong format', error: joiValidation.error.message }); 24 | 25 | sessionItem.socket.emit(SOCKET_RESPONSE_EVENT, response); 26 | 27 | return; 28 | } 29 | 30 | try { 31 | const response: AxiosResponse = await axios.get(`${COUNTRY_REST_BASE_URL}/alpha/${data.code}`); 32 | 33 | if (sessionItem !== undefined) { 34 | const result: object = { rqid: data.rqid, data: response.data }; 35 | 36 | sessionItem.socket.emit(SOCKET_RESPONSE_EVENT, JSON.stringify(result)); 37 | } 38 | } catch (e) { 39 | if (sessionItem !== undefined) { 40 | sessionItem.socket.emit(SOCKET_RESPONSE_EVENT, JSON.stringify({ error: (e as AxiosError).response?.data })); 41 | } 42 | } 43 | } 44 | } 45 | 46 | export { GetCountryTask }; 47 | -------------------------------------------------------------------------------- /server/app/socket/tasks/task.ts: -------------------------------------------------------------------------------- 1 | import joi from 'joi'; 2 | 3 | class SocketTask { 4 | static validateWithDefaultSchema(data: any, joiSchema: joi.ObjectSchema): joi.ValidationResult { 5 | const finalSchema: joi.ObjectSchema = joiSchema.keys({ 6 | rqid: joi.string().required(), 7 | }); 8 | 9 | return finalSchema.validate(data); 10 | } 11 | 12 | static validateWithoutDefaultSchema(data: any, joiSchema: joi.Schema): joi.ValidationResult { 13 | return joiSchema.validate(data); 14 | } 15 | } 16 | 17 | export { SocketTask }; 18 | -------------------------------------------------------------------------------- /server/app/transformers/task/index.ts: -------------------------------------------------------------------------------- 1 | import { Transformer } from '../transformer'; 2 | 3 | class TaskTransformer extends Transformer { 4 | constructor(data: any) { 5 | const properties: string[] = [ 6 | '_id', 'title', 'description', 'date', 'status', 'is_important', 'user', 'created_at', 'updated_at', 7 | ]; 8 | 9 | super(data, properties); 10 | } 11 | } 12 | 13 | export { TaskTransformer }; 14 | -------------------------------------------------------------------------------- /server/app/transformers/transformer.ts: -------------------------------------------------------------------------------- 1 | import lodash from 'lodash'; 2 | 3 | class Transformer { 4 | data: any; 5 | properties: any = {}; 6 | 7 | constructor(data: any, properties: any) { 8 | this.data = data; 9 | this.properties = properties; 10 | } 11 | 12 | transformItem(item: any): any { 13 | if (!item) { 14 | return null; 15 | } 16 | 17 | const itemData: any = lodash.pick(item, this.properties); 18 | 19 | return { ...itemData }; 20 | } 21 | 22 | async transform(): Promise { 23 | if (this.data === undefined || this.data === null) { 24 | return null; 25 | } 26 | 27 | if (!Array.isArray(this.data)) { 28 | return this.transformItem(this.data); 29 | } 30 | 31 | const length: number = this.data.length; 32 | const result: any[] = []; 33 | 34 | for (let i: number = 0; i < length; i += 1) { 35 | result.push(this.transformItem(this.data[i])); 36 | } 37 | 38 | return result; 39 | } 40 | } 41 | 42 | export { Transformer }; 43 | -------------------------------------------------------------------------------- /server/app/transformers/user/index.ts: -------------------------------------------------------------------------------- 1 | import { Transformer } from '../transformer'; 2 | 3 | class UserTransformer extends Transformer { 4 | constructor(data: any) { 5 | const properties: string[] = [ 6 | '_id', 'name', 'email', 'avatar', 'username', 'gender', 'confirmed', 'created_at', 'updated_at', 7 | ]; 8 | 9 | super(data, properties); 10 | } 11 | } 12 | 13 | export { UserTransformer }; 14 | -------------------------------------------------------------------------------- /server/app/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const DB_CONNECTION_SUCCESS = 'Connected to mongo !'; 2 | 3 | export const REGEX = { 4 | ipAddress: /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/, 5 | url: /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/, 6 | email: /^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/, 7 | date: /^([0-9]{2,4})-([0-1][0-9])-([0-3][0-9])(?:( [0-2][0-9]):([0-5][0-9]):([0-5][0-9]))?$/, 8 | }; 9 | 10 | export const AVATAR_UPLOAD_PATH = './public/uploads/avatar'; 11 | -------------------------------------------------------------------------------- /server/app/utils/upload-handler.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import multer from 'multer'; 3 | 4 | /** Storage Avatar */ 5 | const storageAvatar = multer.diskStorage({ 6 | destination: './public/uploads/avatars', 7 | filename(req, file, fn) { 8 | fn(null, `${new Date().getTime().toString()}-${file.fieldname}${path.extname(file.originalname)}`); 9 | }, 10 | }); 11 | 12 | export const uploadAvatar = multer({ 13 | storage: storageAvatar, 14 | limits: { fileSize: 2 * 1024 * 1024 }, 15 | fileFilter(req, file, callback) { 16 | const extension: boolean = ['.png', '.jpg', '.jpeg'].indexOf(path.extname(file.originalname).toLowerCase()) >= 0; 17 | const mimeType: boolean = file.mimetype.indexOf('image') > 0; 18 | 19 | if (extension && mimeType) { 20 | return callback(null, true); 21 | } 22 | 23 | callback(new Error('Invalid file type. Only pictures are allowed !')); 24 | }, 25 | }).single('picture'); 26 | 27 | /** Storage File */ 28 | /* 29 | const storageFile = multer.diskStorage({ 30 | destination: './public/uploads/files', 31 | filename(req, file, fn) { 32 | fn(null, `${new Date().getTime().toString()}-${file.fieldname}${path.extname(file.originalname)}`); 33 | }, 34 | }); 35 | 36 | export const uploadFile = multer({ 37 | storage: storageAvatar, 38 | limits: { fileSize: 50 * 1024 * 1024 }, 39 | fileFilter(req, file, callback) { 40 | const extension: boolean = ['.json', '.csv'].indexOf(path.extname(file.originalname).toLowerCase()) >= 0; 41 | const mimeType: boolean = file.mimetype.indexOf('image') > 0; 42 | if (extension && mimeType) { 43 | return callback(null, true); 44 | } 45 | callback(new Error('Invalid file type. Only JSON and CSV file are allowed !')); 46 | }, 47 | }).single('file');*/ 48 | -------------------------------------------------------------------------------- /server/app/validator/index.ts: -------------------------------------------------------------------------------- 1 | import { Result, ValidationError, validationResult } from 'express-validator'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { ValidatorMethod } from '../core/types'; 4 | 5 | interface IValidator { 6 | validationHandler(req: Request, res: Response, next: NextFunction): Response|NextFunction|void; 7 | } 8 | 9 | type ValidationResultError = { 10 | [string: string]: [string]; 11 | }; 12 | 13 | class Validator implements IValidator { 14 | validationHandler(req: Request, res: Response, next: NextFunction): Response|NextFunction|void { 15 | const errors: Result = validationResult(req); 16 | const result: ValidationResultError = { }; 17 | 18 | if (!errors.isEmpty()) { 19 | errors.array().forEach((item: Object) => { 20 | const { param, msg }: any = item; 21 | 22 | if (result[param]) { 23 | result[param].push(msg); 24 | } else { 25 | result[param] = [msg]; 26 | } 27 | }); 28 | 29 | return res.status(422).json({ errors: result }); 30 | } 31 | 32 | return next(); 33 | } 34 | 35 | public static methods: ValidatorMethod = { 36 | user: { 37 | createUser: 'createUser', 38 | confirmAccount: 'confirmAccount', 39 | loginUser: 'loginUser', 40 | forgotPassword: 'forgotPassword', 41 | resetPassword: 'resetPassword', 42 | updateUser: 'updateUser', 43 | updateUserPassword: 'updateUserPassword', 44 | deleteUser: 'deleteUser', 45 | refreshToken: 'refreshToken', 46 | }, 47 | task: { 48 | createTask: 'createTask', 49 | updateTask: 'updateTask', 50 | }, 51 | }; 52 | } 53 | 54 | export { Validator }; 55 | -------------------------------------------------------------------------------- /server/app/validator/task.validator.ts: -------------------------------------------------------------------------------- 1 | import { check, ValidationChain } from 'express-validator'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { Document } from 'mongoose'; 4 | 5 | import { Locale } from '../core/locale'; 6 | import { Validator } from './index'; 7 | 8 | import { TaskModel } from '../models/task.model'; 9 | import { REGEX } from '../utils/constants'; 10 | 11 | export default { 12 | validate: (method: string): (ValidationChain | ((req: Request, res: Response, next: NextFunction) => void))[] => { 13 | const validator: Validator = new Validator(); 14 | 15 | switch (method) { 16 | case 'createTask': { 17 | return [ 18 | check('title').not().isEmpty().withMessage(() => { return Locale.trans('input.empty'); }), 19 | check('description').not().isEmpty().withMessage(() => { return Locale.trans('input.empty'); }), 20 | check('status').not().isEmpty().withMessage(() => { return Locale.trans('input.empty'); }), 21 | check('date').not().isEmpty().withMessage(() => { return Locale.trans('input.empty'); }), 22 | check('is_important').not().isEmpty().withMessage(() => { return Locale.trans('input.empty'); }), 23 | check('user').not().isEmpty().withMessage(() => { return Locale.trans('input.empty'); }), 24 | check('title') 25 | .custom(async (value: any, { req }: any) => { 26 | const { title, user }: any = req.body; 27 | const task: Document|null = await TaskModel.findOne({ title, user }); 28 | 29 | if (task) { 30 | throw new Error(Locale.trans('input.taken')); 31 | } 32 | }), 33 | check('date') 34 | .custom(async (value: any, { req }: any) => { 35 | if (!REGEX.date.test(req.body.date)) { 36 | throw new Error(Locale.trans('input.date.invalid')); 37 | } 38 | }), 39 | (req: Request, res: Response, next: NextFunction): void => { 40 | validator.validationHandler(req, res, next); 41 | }, 42 | ]; 43 | } 44 | case 'updateTask': { 45 | return [ 46 | check('title').optional().not().isEmpty() 47 | .withMessage(() => { return Locale.trans('input.empty'); }), 48 | check('description').optional().not().isEmpty() 49 | .withMessage(() => { return Locale.trans('input.empty'); }), 50 | check('status').optional().not().isEmpty() 51 | .withMessage(() => { return Locale.trans('input.empty'); }), 52 | check('date').optional().not().isEmpty() 53 | .withMessage(() => { return Locale.trans('input.empty'); }), 54 | check('is_important').optional().not().isEmpty() 55 | .withMessage(() => { return Locale.trans('input.empty'); }), 56 | check('title') 57 | .optional() 58 | .custom(async (value: any, { req }: any) => { 59 | const task: any = await TaskModel.findOne({ _id: req.params.id }); 60 | const taskExist: Document|null = await TaskModel.findOne({ 61 | title: req.body.title, 62 | user: task.user, 63 | }); 64 | const { _id }: any = task; 65 | 66 | if (taskExist && _id.toString() !== taskExist._id.toString()) { 67 | throw new Error(Locale.trans('input.taken')); 68 | } 69 | }), 70 | check('date') 71 | .custom(async (value: any, { req }: any) => { 72 | if (req.body.date && !REGEX.date.test(req.body.date)) { 73 | throw new Error(Locale.trans('input.date.invalid')); 74 | } 75 | }), 76 | (req: Request, res: Response, next: NextFunction): void => { 77 | validator.validationHandler(req, res, next); 78 | }, 79 | ]; 80 | } 81 | default: 82 | return [ 83 | (req: Request, res: Response, next: NextFunction): void => { 84 | validator.validationHandler(req, res, next); 85 | }, 86 | ]; 87 | } 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node_restapi_starter", 3 | "version": "1.0.0", 4 | "description": "A starter to build REST API with NodeJS and Typescript", 5 | "main": "./build/index.js", 6 | "scripts": { 7 | "tsc": "tsc", 8 | "start": "nodemon --watch '*.ts' --exec 'ts-node' ./app/index.ts", 9 | "dev": "ts-node-dev --respawn --transpileOnly ./app/index.ts", 10 | "prod": "tsc && node ./build/index.js", 11 | "test": "mocha -r ts-node/register ./app/tests/*/*.ts --recursive", 12 | "api-designer": "api-designer --port 7011", 13 | "apidoc": "raml2html --theme raml2html-slate-theme -i public/apidoc/api.raml -o public/apidoc/index.html" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/tericcabrel/node-restapi-starter.git" 18 | }, 19 | "author": "Eric Cabrel TIOGO ", 20 | "license": "ISC", 21 | "dependencies": { 22 | "joi": "^17.6.1", 23 | "axios": "^0.27.2", 24 | "bcryptjs": "^2.4.3", 25 | "body-parser": "^1.20.0", 26 | "cookie-parser": "^1.4.6", 27 | "cors": "^2.8.5", 28 | "dotenv": "^16.0.2", 29 | "express": "^4.18.1", 30 | "express-validator": "^6.14.2", 31 | "handlebars": "^4.7.7", 32 | "helmet": "^6.0.0", 33 | "jsonwebtoken": "^8.5.1", 34 | "lodash": "^4.17.21", 35 | "mongoose": "^6.6.1", 36 | "multer": "^1.4.5-lts.1", 37 | "nodemailer": "^6.7.8", 38 | "redis": "^4.3.1", 39 | "socket.io": "^4.5.2", 40 | "winston": "^3.8.2", 41 | "winston-daily-rotate-file": "^4.7.1" 42 | }, 43 | "devDependencies": { 44 | "@types/axios": "^0.14.0", 45 | "@types/bcryptjs": "^2.4.2", 46 | "@types/body-parser": "^1.19.2", 47 | "@types/cookie-parser": "^1.4.3", 48 | "@types/cors": "^2.8.12", 49 | "@types/dotenv": "^8.2.0", 50 | "@types/express": "^4.17.14", 51 | "@types/joi": "^17.2.3", 52 | "@types/helmet": "^4.0.0", 53 | "@types/jest": "^29.0.3", 54 | "@types/jsonwebtoken": "^8.5.9", 55 | "@types/lodash": "^4.14.185", 56 | "@types/mocha": "^9.1.1", 57 | "@types/mongoose": "^5.11.97", 58 | "@types/multer": "^1.4.7", 59 | "@types/node": "^18.7.19", 60 | "@types/nodemailer": "^6.4.6", 61 | "@types/redis": "^4.0.11", 62 | "@types/socket.io": "^3.0.2", 63 | "api-designer": "^0.4.1", 64 | "chai": "^4.3.6", 65 | "chai-http": "^4.3.0", 66 | "mocha": "^10.0.0", 67 | "nodemon": "^2.0.20", 68 | "raml2html": "^7.8.0", 69 | "raml2html-slate-theme": "^2.7.0", 70 | "request": "^2.88.2", 71 | "ts-node-dev": "^2.0.0", 72 | "tslint": "^6.1.3", 73 | "tslint-config-airbnb": "^5.11.2", 74 | "typescript": "^4.8.3" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/confirm-account/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "$2a$08$k17epp5uIjryJzKVON67UuAHRGiPs5dvDMay71/EhOnO4sNnVGf2O" 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/confirm-account/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Account confirmed successfully!" 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/forgot-password/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "user@example.com" 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/forgot-password/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Kindly check your email for further instructions" 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/login/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "user@example.com", 3 | "password": "p@ssw0rd" 4 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/login/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "expiresIn": 86400, 3 | "token": "eyJOeXAioiJKV1QiLAOKICJhbGcioiJIUzI1NiJ9.eyJpc3MioiJqb2UiLA0KICJleHAiojEzMDA4MTkz0DAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" 4 | 5 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/register/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "user@example.com", 3 | "name" : "John DOE", 4 | "username" : "jndoe", 5 | "password" : "p@ssword" 6 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/register/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "User registered successfully!" 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/reset-password/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "password": "p@ssw0rd", 3 | "email_token": "$2a$08$k17epp5uIjryJzKVON67UuAHRGiPs5dvDMay71/EhOnO4sNnVGf2O" 4 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/auth/reset-password/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "password has been reset." 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/tasks/create/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Test Application", 3 | "description": "QA testing on the whole application", 4 | "status": "Pending", 5 | "date": "2019-05-30 09:30:00", 6 | "is_important": true, 7 | "user": "5cee861d04d9f4214dc8dce6" 8 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/tasks/create/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "_id": "5d31c351df1c9dfd93d25786", 3 | "title": "Test Application", 4 | "description": "QA testing on the whole application", 5 | "status": "Pending", 6 | "date": "2019-05-30 09:30:00", 7 | "is_important": true, 8 | "user": "5cee861d04d9f4214dc8dce6", 9 | "created_at": "2019-07-19 14:19:13.463", 10 | "updated_at": "2019-07-19 14:19:13.463" 11 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/tasks/delete/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Task deleted successfully!" 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/tasks/get-all/response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "5d31c351df1c9dfd93d25786", 4 | "title": "Test Application", 5 | "description": "QA testing on the whole application", 6 | "status": "Pending", 7 | "date": "2019-05-30 09:30:00", 8 | "is_important": true, 9 | "user": "5cee861d04d9f4214dc8dce6", 10 | "created_at": "2019-07-19 14:19:13.463", 11 | "updated_at": "2019-07-19 14:19:13.463" 12 | }, 13 | { 14 | "_id": "5caa22a898eb5b14af3e93ec", 15 | "title": "Learn a Tutorial", 16 | "description": "Learn tutorial on microservice architectures", 17 | "status": "Completed", 18 | "date": "2019-05-30 09:30:00", 19 | "is_important": false, 20 | "user": "5cee861d04d9f4214dc8dce6", 21 | "created_at": "2019-07-19 14:19:13.463", 22 | "updated_at": "2019-07-19 14:19:13.463" 23 | } 24 | ] -------------------------------------------------------------------------------- /server/public/apidoc/examples/tasks/update/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Test Application", 3 | "description": "QA testing on the whole application", 4 | "status": "Completed", 5 | "date": "2019-05-30 09:30:00", 6 | "is_important": false 7 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/users/delete/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": " Element successfully deleted !" 3 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/users/get-all/response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "5d31d9d0fd4fc002e68d8f93", 4 | "name" : "John DOE", 5 | "username" : "jndoe", 6 | "email" : "user@example.com", 7 | "gender": "M", 8 | "confirmed": true, 9 | "avatar": "https://api.starter.com/uploads/1557234445-user.png" 10 | }, 11 | { 12 | "_id": "5caa22a898eb5b14af3e93ec", 13 | "name" : "Alice BOB", 14 | "username" : "alibob", 15 | "email" : "alibob@example.com", 16 | "gender": "F", 17 | "confirmed": true, 18 | "avatar": "https://api.starter.com/uploads/1557398373-user.png" 19 | } 20 | ] -------------------------------------------------------------------------------- /server/public/apidoc/examples/users/get-one/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "_id": "5d31d9d0fd4fc002e68d8f93", 3 | "name" : "John DOE", 4 | "username" : "jndoe", 5 | "email" : "user@example.com", 6 | "gender": "M", 7 | "confirmed": true, 8 | "avatar": null 9 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/users/update-password/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "uid": "5caa230498eb5b14af3e93ed", 3 | "password": "Qw3rTy$", 4 | "new_password": "@z3rTy$" 5 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/users/update-password/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "_id": "5d31d9d0fd4fc002e68d8f93", 3 | "name" : "John DOE", 4 | "username" : "jndoe", 5 | "email" : "user@example.com", 6 | "gender": "M", 7 | "confirmed": true, 8 | "avatar": null 9 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/users/update/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Petyr Baelish", 3 | "username": "petblish" 4 | } -------------------------------------------------------------------------------- /server/public/apidoc/examples/users/update/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "_id": "5d31d9d0fd4fc002e68d8f93", 3 | "name" : "John DOE", 4 | "username" : "jndoe", 5 | "email" : "user@example.com", 6 | "gender": "M", 7 | "confirmed": true, 8 | "avatar": null 9 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/confirm-account/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "token": { 7 | "type": "string", 8 | "description": "Token to validate the account of an user" 9 | } 10 | }, 11 | "required": ["token"] 12 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/confirm-account/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "message": { 7 | "type": "string", 8 | "description": "Success message of the account's confirmation" 9 | } 10 | }, 11 | "required": ["message"] 12 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/forgot-password/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "email": { 7 | "type": "string", 8 | "description": "User email address.", 9 | "format": "email" 10 | } 11 | }, 12 | "required": ["email"] 13 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/forgot-password/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "message": { 7 | "type": "string" 8 | } 9 | }, 10 | "required": [ "message"] 11 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/login/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "email": { 7 | "type": "string", 8 | "description": "Email address of the user", 9 | "format": "email" 10 | }, 11 | "password": { 12 | "type": "string", 13 | "description": "Password of the user" 14 | } 15 | }, 16 | "required": ["email", "password"] 17 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/login/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "token": { 7 | "type": "string", 8 | "description": "JWT Access token for secured requests." 9 | }, 10 | "expiresIn": { 11 | "type": "number", 12 | "description": "Time to live of the token in second" 13 | } 14 | }, 15 | "required": ["token", "expiresIn"] 16 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/register/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "email": { 7 | "type": "string", 8 | "description": "User's email address", 9 | "format": "email" 10 | }, 11 | "name" : { 12 | "type": "string", 13 | "description": "User's full name" 14 | }, 15 | "username" : { 16 | "type": "string", 17 | "description": "User's unique username" 18 | }, 19 | "password": { 20 | "type": "string", 21 | "description": "User's password", 22 | "minLength": 5 23 | } 24 | }, 25 | "required": [ "email", "name", "username","password"] 26 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/register/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "message": { 7 | "type": "string", 8 | "description": "Success message of the registration" 9 | } 10 | }, 11 | "required": ["message"] 12 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/reset-password/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "email_token": { 7 | "type": "string", 8 | "description": "User email token." 9 | }, 10 | "password": { 11 | "type": "string", 12 | "description": "The new password of the user." 13 | } 14 | }, 15 | "required": ["email_token", "password"] 16 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/auth/reset-password/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "message": { 7 | "type": "string", 8 | "description": "Success message of the password resetting" 9 | } 10 | }, 11 | "required": [ "message"] 12 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/error-422.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "object", 5 | "description": "The list of errors", 6 | "properties": { 7 | "errors": { 8 | "type": "object", 9 | "properties": { 10 | "*": { 11 | "type": "array", 12 | "items": { 13 | "type": "string" 14 | } 15 | } 16 | } 17 | } 18 | }, 19 | "required": ["errors"] 20 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "message": { 7 | "type": "string", 8 | "description": "Explanatory error message." 9 | } 10 | }, 11 | "required": ["message"] 12 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/errors/400.yml: -------------------------------------------------------------------------------- 1 | description: | 2 | The request completion failed. The description should provide detailed explanation of the reason. 3 | body: 4 | application/json: 5 | schema: !include ../error.json 6 | example: | 7 | { 8 | "message": "Invalid object ID." 9 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/errors/403.yml: -------------------------------------------------------------------------------- 1 | description: The request is forbidden due to missing permissions. 2 | body: 3 | application/json: 4 | schema: !include ../error.json 5 | example: | 6 | { 7 | "message": "You do not have permissions for that request.." 8 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/errors/404.yml: -------------------------------------------------------------------------------- 1 | description: | 2 | The request URI does not match a resource in the API or Resource item not found. 3 | body: 4 | application/json: 5 | schema: !include ../error.json 6 | example: | 7 | { 8 | "message": "Resurce item not found" 9 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/errors/422.yml: -------------------------------------------------------------------------------- 1 | description: The request is forbidden due to missing permissions. 2 | body: 3 | application/json: 4 | schema: !include ../error-422.json 5 | example: | 6 | { 7 | "errors": { 8 | "name": ["This field is required!", "The name should have at least 3 characters"], 9 | "email": ["This field is not valid!"] 10 | } 11 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/errors/500.yml: -------------------------------------------------------------------------------- 1 | description: | 2 | A server internal prevents the error to complete. 3 | body: 4 | application/json: 5 | schema: !include ../error.json 6 | example: | 7 | { 8 | "message": "A server internal error occurs. Please contact us ." 9 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/tasks/create/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "title": { 7 | "type": "string", 8 | "description": "Task title" 9 | }, 10 | "description" : { 11 | "type": "string", 12 | "description": "Task description" 13 | }, 14 | "status" : { 15 | "type": "string", 16 | "description": "Task status. Possible value: `Pending`, `Completed`, ``" 17 | }, 18 | "date": { 19 | "type": "string", 20 | "description": "Time to execute the task" 21 | }, 22 | "is_important": { 23 | "type": "boolean", 24 | "description": "Indicate the task importance" 25 | }, 26 | "user": { 27 | "type": "string", 28 | "description": "Id of the creator of the task" 29 | } 30 | }, 31 | "required": [ "title", "description", "status", "date", "is_important", "user"] 32 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/tasks/create/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "_id": { 7 | "type": "string", 8 | "description": "ID of the task" 9 | }, 10 | "title": { 11 | "type": "string", 12 | "description": "Task title" 13 | }, 14 | "description" : { 15 | "type": "string", 16 | "description": "Task description" 17 | }, 18 | "status" : { 19 | "type": "string", 20 | "description": "Task status. Possible value: `Pending`, `Completed`, ``" 21 | }, 22 | "date": { 23 | "type": "string", 24 | "description": "Time to execute the task" 25 | }, 26 | "is_important": { 27 | "type": "boolean", 28 | "description": "Indicate the task importance" 29 | }, 30 | "user": { 31 | "type": "string", 32 | "description": "Id of the creator of the task" 33 | }, 34 | "created_at": { 35 | "type": "string", 36 | "description": "Task creation date" 37 | }, 38 | "updated_at": { 39 | "type": "string", 40 | "description": "Task update date" 41 | } 42 | }, 43 | "required": [ "_id", "title", "description", "status", "date", "is_important", "user", "created_at", "updated_at" ] 44 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/tasks/delete/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "message": { 7 | "type": "string" 8 | } 9 | }, 10 | "required": [ "message"] 11 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/tasks/get-all/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "array", 5 | "description": "The list of task.", 6 | "items": { 7 | "type": "object", 8 | "properties": { 9 | "_id": { 10 | "type": "string", 11 | "description": "ID of the task" 12 | }, 13 | "title": { 14 | "type": "string", 15 | "description": "Task title" 16 | }, 17 | "description" : { 18 | "type": "string", 19 | "description": "Task description" 20 | }, 21 | "status" : { 22 | "type": "string", 23 | "description": "Task status. Possible value: `Pending`, `Completed`, ``" 24 | }, 25 | "date": { 26 | "type": "string", 27 | "description": "Time to execute the task" 28 | }, 29 | "is_important": { 30 | "type": "boolean", 31 | "description": "Indicate the task importance" 32 | }, 33 | "user": { 34 | "type": "string", 35 | "description": "Id of the creator of the task" 36 | }, 37 | "created_at": { 38 | "type": "string", 39 | "description": "Task creation date" 40 | }, 41 | "updated_at": { 42 | "type": "string", 43 | "description": "Task update date" 44 | } 45 | }, 46 | "required": [ "_id", "title", "description", "status", "date", "is_important", "user", "created_at", "updated_at" ] 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/tasks/update/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "title": { 7 | "type": "string", 8 | "description": "Task title" 9 | }, 10 | "description" : { 11 | "type": "string", 12 | "description": "Task description" 13 | }, 14 | "status" : { 15 | "type": "string", 16 | "description": "Task status. Possible value: `Pending`, `Completed`, ``" 17 | }, 18 | "date": { 19 | "type": "string", 20 | "description": "Time to execute the task" 21 | }, 22 | "is_important": { 23 | "type": "boolean", 24 | "description": "Indicate the task importance" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/delete/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "message": { 7 | "type": "string" 8 | } 9 | }, 10 | "required": [ "message"] 11 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/get-all/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "array", 5 | "description": "The list of users.", 6 | "items": { 7 | "type": "object", 8 | "properties": { 9 | "_id" : { 10 | "type": "string", 11 | "description": "User id" 12 | }, 13 | "name": { 14 | "type": "string", 15 | "description": "User name" 16 | }, 17 | "username" : { 18 | "type": "string", 19 | "description": "User name" 20 | }, 21 | "email" : { 22 | "type": "string", 23 | "description": "User email address", 24 | "format": "email" 25 | }, 26 | "gender": { 27 | "type": "string", 28 | "description": "User gender" 29 | }, 30 | "confirmed": { 31 | "type": "boolean", 32 | "description": "Indciates if account is confirmed or not" 33 | }, 34 | "avatar": { 35 | "type": "string", 36 | "description": "Path of the picture the user" 37 | } 38 | }, 39 | "required": [ "_id", "name", "username", "email", "gender", "confirmed", "avatar"] 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/get-one-responses-200.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/api-doc/schemas/", 4 | "type": "array", 5 | "description": "The list of users.", 6 | "items": { 7 | "type": "object", 8 | "properties": { 9 | "user_id" : { 10 | "type": "string", 11 | "description": "User id" 12 | }, 13 | "name": { 14 | "type": "string", 15 | "description": "User name" 16 | }, 17 | "username" : { 18 | "type": "string", 19 | "description": "User name" 20 | }, 21 | "email" : { 22 | "type": "string", 23 | "description": "User email address", 24 | "format": "email" 25 | } 26 | }, 27 | "required": [ "user_id","name", "username", "email"] 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/get-one/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "_id": { 7 | "type": "string", 8 | "description": "User id" 9 | }, 10 | "email": { 11 | "type": "string", 12 | "description": "User email address", 13 | "format": "email" 14 | }, 15 | "name" : { 16 | "type": "string", 17 | "description": "User name" 18 | }, 19 | "username" : { 20 | "type": "string", 21 | "description": "User name" 22 | }, 23 | "gender": { 24 | "type": "string", 25 | "description": "User gender" 26 | }, 27 | "confirmed": { 28 | "type": "boolean", 29 | "description": "Indciates if account is confirmed or not" 30 | }, 31 | "avatar": { 32 | "type": "null", 33 | "description": "Path of the picture the user" 34 | } 35 | }, 36 | "required": [ "_id", "email", "name", "username", "gender", "confirmed", "avatar"] 37 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/update-password/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "uid" : { 7 | "type": "string", 8 | "description": "User unique ID" 9 | }, 10 | "password": { 11 | "type": "string", 12 | "description": "The current password of the user" 13 | }, 14 | "new_password": { 15 | "type": "string", 16 | "description": "The new password of the user" 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/update-password/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "_id": { 7 | "type": "string", 8 | "description": "User id" 9 | }, 10 | "email": { 11 | "type": "string", 12 | "description": "User email address", 13 | "format": "email" 14 | }, 15 | "name" : { 16 | "type": "string", 17 | "description": "User name" 18 | }, 19 | "username" : { 20 | "type": "string", 21 | "description": "User name" 22 | }, 23 | "gender": { 24 | "type": "string", 25 | "description": "User gender" 26 | }, 27 | "confirmed": { 28 | "type": "boolean", 29 | "description": "Indciates if account is confirmed or not" 30 | }, 31 | "avatar": { 32 | "type": "null", 33 | "description": "Path of the picture the user" 34 | } 35 | }, 36 | "required": [ "_id", "email", "name", "username", "gender", "confirmed", "avatar"] 37 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/update/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "name" : { 7 | "type": "string", 8 | "description": "User name" 9 | }, 10 | "username": { 11 | "type": "string", 12 | "description": "User unique username" 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /server/public/apidoc/schemas/users/update/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://localhost/the-news/api-doc/schemas/", 4 | "type": "object", 5 | "properties": { 6 | "_id": { 7 | "type": "string", 8 | "description": "User id" 9 | }, 10 | "email": { 11 | "type": "string", 12 | "description": "User email address", 13 | "format": "email" 14 | }, 15 | "name" : { 16 | "type": "string", 17 | "description": "User name" 18 | }, 19 | "username" : { 20 | "type": "string", 21 | "description": "User name" 22 | }, 23 | "gender": { 24 | "type": "string", 25 | "description": "User gender" 26 | }, 27 | "confirmed": { 28 | "type": "boolean", 29 | "description": "Indciates if account is confirmed or not" 30 | }, 31 | "avatar": { 32 | "type": "null", 33 | "description": "Path of the picture the user" 34 | } 35 | }, 36 | "required": [ "_id", "email", "name", "username", "gender", "confirmed", "avatar"] 37 | } -------------------------------------------------------------------------------- /server/public/apidoc/security/jwt.yml: -------------------------------------------------------------------------------- 1 | description: | 2 | Authorization based on signed token. 3 | type: x-jwt 4 | describedBy: 5 | headers: 6 | x-access-token: 7 | description: | 8 | The JWT token generated when the user log in. 9 | example: | 10 | eyJOeXAioiJKV1QiLAOKICJhbGcioiJIUzI1NiJ9.eyJpc3MioiJqb2UiLA0KICJleHAiojEzMDA4MTkz0DAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk 11 | responses: 12 | 401: 13 | description: | 14 | Invalid JWT token (corrupted, expired ect). The client should authenticate to get a valid JWT token. 15 | 403: 16 | description: | 17 | You are not allowed to acces to this content -------------------------------------------------------------------------------- /server/public/apidoc/traits/paged.yml: -------------------------------------------------------------------------------- 1 | queryParameters: 2 | before: 3 | description: | 4 | The value of the before cursor. 5 | type: string 6 | after: 7 | description: | 8 | The value of the after cursor. 9 | type: string 10 | limit: 11 | description: | 12 | Maximum number of item in the result set. 13 | type: integer 14 | default: 10 -------------------------------------------------------------------------------- /server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-airbnb", 3 | "rules": { 4 | "max-line-length": [ 5 | true, 6 | 150 7 | ], 8 | "indent": [ 9 | true, 10 | "tabs", 11 | 2 12 | ], 13 | "ter-indent": false, 14 | "newline-before-return": true, 15 | "ter-newline-after-var": [ 16 | true, 17 | "always" 18 | ], 19 | "no-unused-variable": true, 20 | "no-parameter-reassignment": true, 21 | "typedef": [ 22 | true, 23 | "call-signature", 24 | "arrow-call-signature", 25 | "parameter", 26 | "arrow-parameter", 27 | "property-declaration", 28 | "variable-declaration", 29 | "member-variable-declaration", 30 | "object-destructuring", 31 | "array-destructuring" 32 | ], 33 | } 34 | } 35 | --------------------------------------------------------------------------------