├── .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 | You need to enable JavaScript to run this app.
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 | onClose()}> CLOSE
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 |
6 | Typescript support
7 | Dynamic loading
8 | Internationalization
9 | Redux with promise
10 | Socket Integration
11 |
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' &&
}
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 |
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 |
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 |
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 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | Eric Cabrel
48 |
49 |
50 | { history.push('/app/profile'); }}>
51 |
52 |
53 |
54 |
55 |
56 |
57 | {}}>
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
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 |
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 | {/*
*/}
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 |
63 |
64 |
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 |
--------------------------------------------------------------------------------