initial commit
This commit is contained in:
commit
9cfbf9ba17
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
FROM node:8-alpine
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ARG NODE_ENV
|
||||||
|
ENV NODE_ENV $NODE_ENV
|
||||||
|
|
||||||
|
RUN mkdir /app
|
||||||
|
WORKDIR /app
|
||||||
|
ADD package.json yarn.lock /app/
|
||||||
|
RUN yarn --pure-lockfile
|
||||||
|
ADD . /app
|
||||||
|
|
||||||
|
CMD ["yarn", "docker:start"]
|
153
README.md
Normal file
153
README.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
# Express ES2017 REST API Boilerplate
|
||||||
|
|
||||||
|
|
||||||
|
Boilerplate/Generator/Starter Project for building RESTful APIs and microservices using Node.js, Express and MongoDB
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- No transpilers, just vanilla javascript
|
||||||
|
- ES2017 latest features like Async/Await
|
||||||
|
- CORS enabled
|
||||||
|
- Uses [yarn](https://yarnpkg.com)
|
||||||
|
- Express + MongoDB ([Mongoose](http://mongoosejs.com/))
|
||||||
|
- Consistent coding styles with [editorconfig](http://editorconfig.org)
|
||||||
|
- [Docker](https://www.docker.com/) support
|
||||||
|
- Uses [helmet](https://github.com/helmetjs/helmet) to set some HTTP headers for security
|
||||||
|
- Load environment variables from .env files with [dotenv](https://github.com/rolodato/dotenv-safe)
|
||||||
|
- Request validation with [joi](https://github.com/hapijs/joi)
|
||||||
|
- Gzip compression with [compression](https://github.com/expressjs/compression)
|
||||||
|
- Linting with [eslint](http://eslint.org)
|
||||||
|
- Tests with [mocha](https://mochajs.org), [chai](http://chaijs.com) and [sinon](http://sinonjs.org)
|
||||||
|
- Code coverage with [istanbul](https://istanbul.js.org) and [coveralls](https://coveralls.io)
|
||||||
|
- Git hooks with [husky](https://github.com/typicode/husky)
|
||||||
|
- Logging with [morgan](https://github.com/expressjs/morgan)
|
||||||
|
- Authentication and Authorization with [passport](http://passportjs.org)
|
||||||
|
- API documentation generation with [apidoc](http://apidocjs.com)
|
||||||
|
- Continuous integration support with [travisCI](https://travis-ci.org)
|
||||||
|
- Monitoring with [pm2](https://github.com/Unitech/pm2)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [Node v7.6+](https://nodejs.org/en/download/current/) or [Docker](https://www.docker.com/)
|
||||||
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
#### Clone the repo and make it yours:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --depth 1 https://github.com/krishfsousa/sg-node-express-rest-api
|
||||||
|
cd sg-node-express-rest-api
|
||||||
|
rm -rf .git
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Set environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running in Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# lint code with ESLint
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# try to fix ESLint errors
|
||||||
|
yarn lint:fix
|
||||||
|
|
||||||
|
# lint and watch for changes
|
||||||
|
yarn lint:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run all tests with Mocha
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# run unit tests
|
||||||
|
yarn test:unit
|
||||||
|
|
||||||
|
# run integration tests
|
||||||
|
yarn test:integration
|
||||||
|
|
||||||
|
# run all tests and watch for changes
|
||||||
|
yarn test:watch
|
||||||
|
|
||||||
|
# open nyc test coverage reports
|
||||||
|
yarn coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run lint and tests
|
||||||
|
yarn validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# show logs in production
|
||||||
|
pm2 logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# generate and open api documentation
|
||||||
|
yarn docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run container locally
|
||||||
|
yarn docker:dev
|
||||||
|
|
||||||
|
# run container in production
|
||||||
|
yarn docker:prod
|
||||||
|
|
||||||
|
# run tests
|
||||||
|
yarn docker:test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Set your server ip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEPLOY_SERVER=127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace my Docker username with yours:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run deploy script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn deploy
|
||||||
|
```
|
||||||
|
|
12
deploy.sh
Normal file
12
deploy.sh
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
docker build -t krishfsousa/sg-node-express-rest-api .
|
||||||
|
docker push krishfsousa/sg-node-express-rest-api
|
||||||
|
|
||||||
|
ssh deploy@$DEPLOY_SERVER << EOF
|
||||||
|
docker pull krishfsousa/sg-node-express-rest-api
|
||||||
|
docker stop api-boilerplate || true
|
||||||
|
docker rm api-boilerplate || true
|
||||||
|
docker rmi krishfsousa/sg-node-express-rest-api:current || true
|
||||||
|
docker tag krishfsousa/sg-node-express-rest-api:latest krishfsousa/sg-node-express-rest-api:current
|
||||||
|
docker run -d --restart always --name api-boilerplate -p 3000:3000 krishfsousa/sg-node-express-rest-api:current
|
||||||
|
EOF
|
6
docker-compose.dev.yml
Normal file
6
docker-compose.dev.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
version: "2"
|
||||||
|
services:
|
||||||
|
boilerplate-api:
|
||||||
|
command: yarn dev -- -L
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
6
docker-compose.prod.yml
Normal file
6
docker-compose.prod.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
version: "2"
|
||||||
|
services:
|
||||||
|
boilerplate-api:
|
||||||
|
command: yarn start
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
6
docker-compose.test.yml
Normal file
6
docker-compose.test.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
version: "2"
|
||||||
|
services:
|
||||||
|
boilerplate-api:
|
||||||
|
command: yarn test
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=test
|
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
version: "2"
|
||||||
|
services:
|
||||||
|
boilerplate-api:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- MONGO_URI=mongodb://mongodb:27017/sg-node-express-rest-api
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- mongodb
|
||||||
|
|
||||||
|
mongodb:
|
||||||
|
image: mongo
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
100
package.json
Normal file
100
package.json
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"name": "sg-node-express-rest-api",
|
||||||
|
"version": "0.0.2",
|
||||||
|
"description": "Sentientgeeks Boilerplate Node Express RESTApi ES2017",
|
||||||
|
"author": "sentientgeeks",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8",
|
||||||
|
"yarn": "*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"precommit": "yarn lint",
|
||||||
|
"prestart": "yarn docs",
|
||||||
|
"start": "cross-env NODE_ENV=production pm2 start ./src/index.js",
|
||||||
|
"dev": "nodemon ./src/index.js",
|
||||||
|
"lint": "eslint ./src/ --ignore-path .gitignore --ignore-pattern internals/scripts",
|
||||||
|
"lint:fix": "yarn lint --fix",
|
||||||
|
"lint:watch": "yarn lint --watch",
|
||||||
|
"test": "cross-env NODE_ENV=test nyc --reporter=html --reporter=text mocha --timeout 20000 --exit --recursive src/api/tests",
|
||||||
|
"test:unit": "cross-env NODE_ENV=test mocha src/api/tests/unit",
|
||||||
|
"test:integration": "cross-env NODE_ENV=test mocha --timeout 20000 --exit src/api/tests/integration",
|
||||||
|
"test:watch": "cross-env NODE_ENV=test mocha --watch src/api/tests/unit",
|
||||||
|
"coverage": "nyc report --reporter=text-lcov | coveralls",
|
||||||
|
"postcoverage": "open-cli coverage/lcov-report/index.html",
|
||||||
|
"validate": "yarn lint && yarn test",
|
||||||
|
"postpublish": "git push --tags",
|
||||||
|
"deploy": "sh ./deploy.sh",
|
||||||
|
"docs": "apidoc -i src -o docs",
|
||||||
|
"postdocs": "open-cli docs/index.html",
|
||||||
|
"docker:start": "cross-env NODE_ENV=production pm2-docker start ./src/index.js",
|
||||||
|
"docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up",
|
||||||
|
"docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up",
|
||||||
|
"docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://krishbhatt@bitbucket.org/krishbhatt/sg-node-express-rest-api.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"express",
|
||||||
|
"node",
|
||||||
|
"node.js",
|
||||||
|
"mongodb",
|
||||||
|
"mongoose",
|
||||||
|
"passport",
|
||||||
|
"es6",
|
||||||
|
"es7",
|
||||||
|
"es8",
|
||||||
|
"es2017",
|
||||||
|
"mocha",
|
||||||
|
"istanbul",
|
||||||
|
"nyc",
|
||||||
|
"eslint",
|
||||||
|
"Travis CI",
|
||||||
|
"coveralls",
|
||||||
|
"REST",
|
||||||
|
"API",
|
||||||
|
"boilerplate",
|
||||||
|
"generator",
|
||||||
|
"starter project"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.19.0",
|
||||||
|
"bcryptjs": "2.4.3",
|
||||||
|
"bluebird": "^3.5.0",
|
||||||
|
"body-parser": "^1.17.0",
|
||||||
|
"compression": "^1.6.2",
|
||||||
|
"cors": "^2.8.3",
|
||||||
|
"cross-env": "^6.0.3",
|
||||||
|
"dotenv-safe": "^6.0.0",
|
||||||
|
"email-templates": "^6.0.3",
|
||||||
|
"express": "^4.15.2",
|
||||||
|
"express-validation": "^1.0.2",
|
||||||
|
"nodemailer": "^6.3.1",
|
||||||
|
"passport": "^0.4.0",
|
||||||
|
"passport-http-bearer": "^1.0.1",
|
||||||
|
"passport-jwt": "4.0.0",
|
||||||
|
"pm2": "^4.2.0",
|
||||||
|
"pug": "^2.0.4",
|
||||||
|
"uuid": "^3.1.0",
|
||||||
|
"winston": "^3.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"apidoc": "^0.17.5",
|
||||||
|
"chai": "^4.1.0",
|
||||||
|
"chai-as-promised": "^7.1.1",
|
||||||
|
"coveralls": "^3.0.0",
|
||||||
|
"eslint": "^6.4.0",
|
||||||
|
"eslint-config-airbnb-base": "^12.0.1",
|
||||||
|
"eslint-plugin-import": "^2.2.0",
|
||||||
|
"husky": "^3.0.7",
|
||||||
|
"mocha": "^6.2.2",
|
||||||
|
"nodemon": "^2.0.1",
|
||||||
|
"nyc": "^14.1.1",
|
||||||
|
"opn-cli": "^5.0.0",
|
||||||
|
"sinon": "^7.5.0",
|
||||||
|
"sinon-chai": "^3.0.0",
|
||||||
|
"supertest": "^4.0.2"
|
||||||
|
}
|
||||||
|
}
|
146
src/api/controllers/auth.controller.js
Normal file
146
src/api/controllers/auth.controller.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
const httpStatus = require('http-status');
|
||||||
|
const User = require('../models/user.model');
|
||||||
|
const RefreshToken = require('../models/refreshToken.model');
|
||||||
|
const PasswordResetToken = require('../models/passwordResetToken.model');
|
||||||
|
const moment = require('moment-timezone');
|
||||||
|
const { jwtExpirationInterval } = require('../../config/vars');
|
||||||
|
const { omit } = require('lodash');
|
||||||
|
const APIError = require('../utils/APIError');
|
||||||
|
const emailProvider = require('../services/emails/emailProvider');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a formated object with tokens
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function generateTokenResponse(user, accessToken) {
|
||||||
|
const tokenType = 'Bearer';
|
||||||
|
const refreshToken = RefreshToken.generate(user).token;
|
||||||
|
const expiresIn = moment().add(jwtExpirationInterval, 'minutes');
|
||||||
|
return {
|
||||||
|
tokenType,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns jwt token if registration was successful
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.register = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userData = omit(req.body, 'role');
|
||||||
|
const user = await new User(userData).save();
|
||||||
|
const userTransformed = user.transform();
|
||||||
|
const token = generateTokenResponse(user, user.token());
|
||||||
|
res.status(httpStatus.CREATED);
|
||||||
|
return res.json({ token, user: userTransformed });
|
||||||
|
} catch (error) {
|
||||||
|
return next(User.checkDuplicateEmail(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns jwt token if valid username and password is provided
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.login = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { user, accessToken } = await User.findAndGenerateToken(req.body);
|
||||||
|
const token = generateTokenResponse(user, accessToken);
|
||||||
|
const userTransformed = user.transform();
|
||||||
|
return res.json({ token, user: userTransformed });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* login with an existing user or creates a new one if valid accessToken token
|
||||||
|
* Returns jwt token
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.oAuth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { user } = req;
|
||||||
|
const accessToken = user.token();
|
||||||
|
const token = generateTokenResponse(user, accessToken);
|
||||||
|
const userTransformed = user.transform();
|
||||||
|
return res.json({ token, user: userTransformed });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new jwt when given a valid refresh token
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.refresh = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, refreshToken } = req.body;
|
||||||
|
const refreshObject = await RefreshToken.findOneAndRemove({
|
||||||
|
userEmail: email,
|
||||||
|
token: refreshToken,
|
||||||
|
});
|
||||||
|
const { user, accessToken } = await User.findAndGenerateToken({ email, refreshObject });
|
||||||
|
const response = generateTokenResponse(user, accessToken);
|
||||||
|
return res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.sendPasswordReset = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email } = req.body;
|
||||||
|
const user = await User.findOne({ email }).exec();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const passwordResetObj = await PasswordResetToken.generate(user);
|
||||||
|
emailProvider.sendPasswordReset(passwordResetObj);
|
||||||
|
res.status(httpStatus.OK);
|
||||||
|
return res.json('success');
|
||||||
|
}
|
||||||
|
throw new APIError({
|
||||||
|
status: httpStatus.UNAUTHORIZED,
|
||||||
|
message: 'No account found with that email',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.resetPassword = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password, resetToken } = req.body;
|
||||||
|
const resetTokenObject = await PasswordResetToken.findOneAndRemove({
|
||||||
|
userEmail: email,
|
||||||
|
resetToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = {
|
||||||
|
status: httpStatus.UNAUTHORIZED,
|
||||||
|
isPublic: true,
|
||||||
|
};
|
||||||
|
if (!resetTokenObject) {
|
||||||
|
err.message = 'Cannot find matching reset token';
|
||||||
|
throw new APIError(err);
|
||||||
|
}
|
||||||
|
if (moment().isAfter(resetTokenObject.expires)) {
|
||||||
|
err.message = 'Reset token is expired';
|
||||||
|
throw new APIError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ email: resetTokenObject.userEmail }).exec();
|
||||||
|
user.password = password;
|
||||||
|
await user.save();
|
||||||
|
emailProvider.sendPasswordChangeEmail(user);
|
||||||
|
|
||||||
|
res.status(httpStatus.OK);
|
||||||
|
return res.json('Password Updated');
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
102
src/api/controllers/user.controller.js
Normal file
102
src/api/controllers/user.controller.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
const httpStatus = require('http-status');
|
||||||
|
const { omit } = require('lodash');
|
||||||
|
const User = require('../models/user.model');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load user and append to req.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.load = async (req, res, next, id) => {
|
||||||
|
try {
|
||||||
|
const user = await User.get(id);
|
||||||
|
req.locals = { user };
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.get = (req, res) => res.json(req.locals.user.transform());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logged in user info
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.loggedIn = (req, res) => res.json(req.user.transform());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new user
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.create = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = new User(req.body);
|
||||||
|
const savedUser = await user.save();
|
||||||
|
res.status(httpStatus.CREATED);
|
||||||
|
res.json(savedUser.transform());
|
||||||
|
} catch (error) {
|
||||||
|
next(User.checkDuplicateEmail(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace existing user
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.replace = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { user } = req.locals;
|
||||||
|
const newUser = new User(req.body);
|
||||||
|
const ommitRole = user.role !== 'admin' ? 'role' : '';
|
||||||
|
const newUserObject = omit(newUser.toObject(), '_id', ommitRole);
|
||||||
|
|
||||||
|
|
||||||
|
res.json(savedUser.transform());
|
||||||
|
} catch (error) {
|
||||||
|
next(User.checkDuplicateEmail(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing user
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.update = (req, res, next) => {
|
||||||
|
const ommitRole = req.locals.user.role !== 'admin' ? 'role' : '';
|
||||||
|
const updatedUser = omit(req.body, ommitRole);
|
||||||
|
const user = Object.assign(req.locals.user, updatedUser);
|
||||||
|
|
||||||
|
user.save()
|
||||||
|
.then(savedUser => res.json(savedUser.transform()))
|
||||||
|
.catch(e => next(User.checkDuplicateEmail(e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user list
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.list = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const users = await User.list(req.query);
|
||||||
|
const transformedUsers = users.map(user => user.transform());
|
||||||
|
res.json(transformedUsers);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.remove = (req, res, next) => {
|
||||||
|
const { user } = req.locals;
|
||||||
|
|
||||||
|
user.remove()
|
||||||
|
.then(() => res.status(httpStatus.NO_CONTENT).end())
|
||||||
|
.catch(e => next(e));
|
||||||
|
};
|
54
src/api/middlewares/auth.js
Normal file
54
src/api/middlewares/auth.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
const httpStatus = require('http-status');
|
||||||
|
const passport = require('passport');
|
||||||
|
const User = require('../models/user.model');
|
||||||
|
const APIError = require('../utils/APIError');
|
||||||
|
|
||||||
|
const ADMIN = 'admin';
|
||||||
|
const LOGGED_USER = '_loggedUser';
|
||||||
|
|
||||||
|
const handleJWT = (req, res, next, roles) => async (err, user, info) => {
|
||||||
|
const error = err || info;
|
||||||
|
const logIn = Promise.promisify(req.logIn);
|
||||||
|
const apiError = new APIError({
|
||||||
|
message: error ? error.message : 'Unauthorized',
|
||||||
|
status: httpStatus.UNAUTHORIZED,
|
||||||
|
stack: error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (error || !user) throw error;
|
||||||
|
await logIn(user, { session: false });
|
||||||
|
} catch (e) {
|
||||||
|
return next(apiError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles === LOGGED_USER) {
|
||||||
|
if (user.role !== 'admin' && req.params.userId !== user._id.toString()) {
|
||||||
|
apiError.status = httpStatus.FORBIDDEN;
|
||||||
|
apiError.message = 'Forbidden';
|
||||||
|
return next(apiError);
|
||||||
|
}
|
||||||
|
} else if (!roles.includes(user.role)) {
|
||||||
|
apiError.status = httpStatus.FORBIDDEN;
|
||||||
|
apiError.message = 'Forbidden';
|
||||||
|
return next(apiError);
|
||||||
|
} else if (err || !user) {
|
||||||
|
return next(apiError);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.ADMIN = ADMIN;
|
||||||
|
exports.LOGGED_USER = LOGGED_USER;
|
||||||
|
|
||||||
|
exports.authorize = (roles = User.roles) => (req, res, next) =>
|
||||||
|
passport.authenticate(
|
||||||
|
'jwt', { session: false },
|
||||||
|
handleJWT(req, res, next, roles),
|
||||||
|
)(req, res, next);
|
||||||
|
|
||||||
|
exports.oAuth = service =>
|
||||||
|
passport.authenticate(service, { session: false });
|
62
src/api/middlewares/error.js
Normal file
62
src/api/middlewares/error.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
const httpStatus = require('http-status');
|
||||||
|
const expressValidation = require('express-validation');
|
||||||
|
const APIError = require('../utils/APIError');
|
||||||
|
const { env } = require('../../config/vars');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error handler. Send stacktrace only during development
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
const handler = (err, req, res, next) => {
|
||||||
|
const response = {
|
||||||
|
code: err.status,
|
||||||
|
message: err.message || httpStatus[err.status],
|
||||||
|
errors: err.errors,
|
||||||
|
stack: err.stack,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (env !== 'development') {
|
||||||
|
delete response.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(err.status);
|
||||||
|
res.json(response);
|
||||||
|
};
|
||||||
|
exports.handler = handler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If error is not an instanceOf APIError, convert it.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.converter = (err, req, res, next) => {
|
||||||
|
let convertedError = err;
|
||||||
|
|
||||||
|
if (err instanceof expressValidation.ValidationError) {
|
||||||
|
convertedError = new APIError({
|
||||||
|
message: 'Validation Error',
|
||||||
|
errors: err.errors,
|
||||||
|
status: err.status,
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
} else if (!(err instanceof APIError)) {
|
||||||
|
convertedError = new APIError({
|
||||||
|
message: err.message,
|
||||||
|
status: err.status,
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(convertedError, req, res);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catch 404 and forward to error handler
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.notFound = (req, res, next) => {
|
||||||
|
const err = new APIError({
|
||||||
|
message: 'Not found',
|
||||||
|
status: httpStatus.NOT_FOUND,
|
||||||
|
});
|
||||||
|
return handler(err, req, res);
|
||||||
|
};
|
57
src/api/models/passwordResetToken.model.js
Normal file
57
src/api/models/passwordResetToken.model.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const moment = require('moment-timezone');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Token Schema
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const passwordResetTokenSchema = new mongoose.Schema({
|
||||||
|
resetToken: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
type: 'String',
|
||||||
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
expires: { type: Date },
|
||||||
|
});
|
||||||
|
|
||||||
|
passwordResetTokenSchema.statics = {
|
||||||
|
/**
|
||||||
|
* Generate a reset token object and saves it into the database
|
||||||
|
*
|
||||||
|
* @param {User} user
|
||||||
|
* @returns {ResetToken}
|
||||||
|
*/
|
||||||
|
async generate(user) {
|
||||||
|
const userId = user._id;
|
||||||
|
const userEmail = user.email;
|
||||||
|
const resetToken = `${userId}.${crypto.randomBytes(40).toString('hex')}`;
|
||||||
|
const expires = moment()
|
||||||
|
.add(2, 'hours')
|
||||||
|
.toDate();
|
||||||
|
const ResetTokenObject = new PasswordResetToken({
|
||||||
|
resetToken,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
expires,
|
||||||
|
});
|
||||||
|
await ResetTokenObject.save();
|
||||||
|
return ResetTokenObject;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef RefreshToken
|
||||||
|
*/
|
||||||
|
const PasswordResetToken = mongoose.model('PasswordResetToken', passwordResetTokenSchema);
|
||||||
|
module.exports = PasswordResetToken;
|
54
src/api/models/refreshToken.model.js
Normal file
54
src/api/models/refreshToken.model.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const moment = require('moment-timezone');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Token Schema
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const refreshTokenSchema = new mongoose.Schema({
|
||||||
|
token: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
type: 'String',
|
||||||
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
expires: { type: Date },
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshTokenSchema.statics = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a refresh token object and saves it into the database
|
||||||
|
*
|
||||||
|
* @param {User} user
|
||||||
|
* @returns {RefreshToken}
|
||||||
|
*/
|
||||||
|
generate(user) {
|
||||||
|
const userId = user._id;
|
||||||
|
const userEmail = user.email;
|
||||||
|
const token = `${userId}.${crypto.randomBytes(40).toString('hex')}`;
|
||||||
|
const expires = moment().add(30, 'days').toDate();
|
||||||
|
const tokenObject = new RefreshToken({
|
||||||
|
token, userId, userEmail, expires,
|
||||||
|
});
|
||||||
|
tokenObject.save();
|
||||||
|
return tokenObject;
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef RefreshToken
|
||||||
|
*/
|
||||||
|
const RefreshToken = mongoose.model('RefreshToken', refreshTokenSchema);
|
||||||
|
module.exports = RefreshToken;
|
236
src/api/models/user.model.js
Normal file
236
src/api/models/user.model.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const httpStatus = require('http-status');
|
||||||
|
const { omitBy, isNil } = require('lodash');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const moment = require('moment-timezone');
|
||||||
|
const jwt = require('jwt-simple');
|
||||||
|
const uuidv4 = require('uuid/v4');
|
||||||
|
const APIError = require('../utils/APIError');
|
||||||
|
const { env, jwtSecret, jwtExpirationInterval } = require('../../config/vars');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Roles
|
||||||
|
*/
|
||||||
|
const roles = ['user', 'admin'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Schema
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const userSchema = new mongoose.Schema({
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
match: /^\S+@\S+\.\S+$/,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
minlength: 6,
|
||||||
|
maxlength: 128,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
maxlength: 128,
|
||||||
|
index: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
facebook: String,
|
||||||
|
google: String,
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: String,
|
||||||
|
enum: roles,
|
||||||
|
default: 'user',
|
||||||
|
},
|
||||||
|
picture: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
timestamps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add your
|
||||||
|
* - pre-save hooks
|
||||||
|
* - validations
|
||||||
|
* - virtuals
|
||||||
|
*/
|
||||||
|
userSchema.pre('save', async function save(next) {
|
||||||
|
try {
|
||||||
|
if (!this.isModified('password')) return next();
|
||||||
|
|
||||||
|
const rounds = env === 'test' ? 1 : 10;
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(this.password, rounds);
|
||||||
|
this.password = hash;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Methods
|
||||||
|
*/
|
||||||
|
userSchema.method({
|
||||||
|
transform() {
|
||||||
|
const transformed = {};
|
||||||
|
const fields = ['id', 'name', 'email', 'picture', 'role', 'createdAt'];
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
transformed[field] = this[field];
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
},
|
||||||
|
|
||||||
|
token() {
|
||||||
|
const payload = {
|
||||||
|
exp: moment().add(jwtExpirationInterval, 'minutes').unix(),
|
||||||
|
iat: moment().unix(),
|
||||||
|
sub: this._id,
|
||||||
|
};
|
||||||
|
return jwt.encode(payload, jwtSecret);
|
||||||
|
},
|
||||||
|
|
||||||
|
async passwordMatches(password) {
|
||||||
|
return bcrypt.compare(password, this.password);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statics
|
||||||
|
*/
|
||||||
|
userSchema.statics = {
|
||||||
|
|
||||||
|
roles,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user
|
||||||
|
*
|
||||||
|
* @param {ObjectId} id - The objectId of user.
|
||||||
|
* @returns {Promise<User, APIError>}
|
||||||
|
*/
|
||||||
|
async get(id) {
|
||||||
|
try {
|
||||||
|
let user;
|
||||||
|
|
||||||
|
if (mongoose.Types.ObjectId.isValid(id)) {
|
||||||
|
user = await this.findById(id).exec();
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new APIError({
|
||||||
|
message: 'User does not exist',
|
||||||
|
status: httpStatus.NOT_FOUND,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user by email and tries to generate a JWT token
|
||||||
|
*
|
||||||
|
* @param {ObjectId} id - The objectId of user.
|
||||||
|
* @returns {Promise<User, APIError>}
|
||||||
|
*/
|
||||||
|
async findAndGenerateToken(options) {
|
||||||
|
const { email, password, refreshObject } = options;
|
||||||
|
if (!email) throw new APIError({ message: 'An email is required to generate a token' });
|
||||||
|
|
||||||
|
const user = await this.findOne({ email }).exec();
|
||||||
|
const err = {
|
||||||
|
status: httpStatus.UNAUTHORIZED,
|
||||||
|
isPublic: true,
|
||||||
|
};
|
||||||
|
if (password) {
|
||||||
|
if (user && await user.passwordMatches(password)) {
|
||||||
|
return { user, accessToken: user.token() };
|
||||||
|
}
|
||||||
|
err.message = 'Incorrect email or password';
|
||||||
|
} else if (refreshObject && refreshObject.userEmail === email) {
|
||||||
|
if (moment(refreshObject.expires).isBefore()) {
|
||||||
|
err.message = 'Invalid refresh token.';
|
||||||
|
} else {
|
||||||
|
return { user, accessToken: user.token() };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err.message = 'Incorrect email or refreshToken';
|
||||||
|
}
|
||||||
|
throw new APIError(err);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List users in descending order of 'createdAt' timestamp.
|
||||||
|
*
|
||||||
|
* @param {number} skip - Number of users to be skipped.
|
||||||
|
* @param {number} limit - Limit number of users to be returned.
|
||||||
|
* @returns {Promise<User[]>}
|
||||||
|
*/
|
||||||
|
list({
|
||||||
|
page = 1, perPage = 30, name, email, role,
|
||||||
|
}) {
|
||||||
|
const options = omitBy({ name, email, role }, isNil);
|
||||||
|
|
||||||
|
return this.find(options)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(perPage * (page - 1))
|
||||||
|
.limit(perPage)
|
||||||
|
.exec();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return new validation error
|
||||||
|
* if error is a mongoose duplicate key error
|
||||||
|
*
|
||||||
|
* @param {Error} error
|
||||||
|
* @returns {Error|APIError}
|
||||||
|
*/
|
||||||
|
checkDuplicateEmail(error) {
|
||||||
|
if (error.name === 'MongoError' && error.code === 11000) {
|
||||||
|
return new APIError({
|
||||||
|
message: 'Validation Error',
|
||||||
|
errors: [{
|
||||||
|
field: 'email',
|
||||||
|
location: 'body',
|
||||||
|
messages: ['"email" already exists'],
|
||||||
|
}],
|
||||||
|
status: httpStatus.CONFLICT,
|
||||||
|
isPublic: true,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async oAuthLogin({
|
||||||
|
service, id, email, name, picture,
|
||||||
|
}) {
|
||||||
|
const user = await this.findOne({ $or: [{ [`services.${service}`]: id }, { email }] });
|
||||||
|
if (user) {
|
||||||
|
user.services[service] = id;
|
||||||
|
if (!user.name) user.name = name;
|
||||||
|
if (!user.picture) user.picture = picture;
|
||||||
|
return user.save();
|
||||||
|
}
|
||||||
|
const password = uuidv4();
|
||||||
|
return this.create({
|
||||||
|
services: { [service]: id }, email, password, name, picture,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef User
|
||||||
|
*/
|
||||||
|
module.exports = mongoose.model('User', userSchema);
|
150
src/api/routes/v1/auth.route.js
Normal file
150
src/api/routes/v1/auth.route.js
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const validate = require('express-validation');
|
||||||
|
const controller = require('../../controllers/auth.controller');
|
||||||
|
const oAuthLogin = require('../../middlewares/auth').oAuth;
|
||||||
|
const {
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
oAuth,
|
||||||
|
refresh,
|
||||||
|
sendPasswordReset,
|
||||||
|
passwordReset,
|
||||||
|
} = require('../../validations/auth.validation');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} v1/auth/register Register
|
||||||
|
* @apiDescription Register a new user
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName Register
|
||||||
|
* @apiGroup Auth
|
||||||
|
* @apiPermission public
|
||||||
|
*
|
||||||
|
* @apiParam {String} email User's email
|
||||||
|
* @apiParam {String{6..128}} password User's password
|
||||||
|
*
|
||||||
|
* @apiSuccess (Created 201) {String} token.tokenType Access Token's type
|
||||||
|
* @apiSuccess (Created 201) {String} token.accessToken Authorization Token
|
||||||
|
* @apiSuccess (Created 201) {String} token.refreshToken Token to get a new accessToken
|
||||||
|
* after expiration time
|
||||||
|
* @apiSuccess (Created 201) {Number} token.expiresIn Access Token's expiration time
|
||||||
|
* in miliseconds
|
||||||
|
* @apiSuccess (Created 201) {String} token.timezone The server's Timezone
|
||||||
|
*
|
||||||
|
* @apiSuccess (Created 201) {String} user.id User's id
|
||||||
|
* @apiSuccess (Created 201) {String} user.name User's name
|
||||||
|
* @apiSuccess (Created 201) {String} user.email User's email
|
||||||
|
* @apiSuccess (Created 201) {String} user.role User's role
|
||||||
|
* @apiSuccess (Created 201) {Date} user.createdAt Timestamp
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
*/
|
||||||
|
router.route('/register')
|
||||||
|
.post(validate(register), controller.register);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} v1/auth/login Login
|
||||||
|
* @apiDescription Get an accessToken
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName Login
|
||||||
|
* @apiGroup Auth
|
||||||
|
* @apiPermission public
|
||||||
|
*
|
||||||
|
* @apiParam {String} email User's email
|
||||||
|
* @apiParam {String{..128}} password User's password
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} token.tokenType Access Token's type
|
||||||
|
* @apiSuccess {String} token.accessToken Authorization Token
|
||||||
|
* @apiSuccess {String} token.refreshToken Token to get a new accessToken
|
||||||
|
* after expiration time
|
||||||
|
* @apiSuccess {Number} token.expiresIn Access Token's expiration time
|
||||||
|
* in miliseconds
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} user.id User's id
|
||||||
|
* @apiSuccess {String} user.name User's name
|
||||||
|
* @apiSuccess {String} user.email User's email
|
||||||
|
* @apiSuccess {String} user.role User's role
|
||||||
|
* @apiSuccess {Date} user.createdAt Timestamp
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Incorrect email or password
|
||||||
|
*/
|
||||||
|
router.route('/login')
|
||||||
|
.post(validate(login), controller.login);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} v1/auth/refresh-token Refresh Token
|
||||||
|
* @apiDescription Refresh expired accessToken
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName RefreshToken
|
||||||
|
* @apiGroup Auth
|
||||||
|
* @apiPermission public
|
||||||
|
*
|
||||||
|
* @apiParam {String} email User's email
|
||||||
|
* @apiParam {String} refreshToken Refresh token aquired when user logged in
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} tokenType Access Token's type
|
||||||
|
* @apiSuccess {String} accessToken Authorization Token
|
||||||
|
* @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time
|
||||||
|
* @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Incorrect email or refreshToken
|
||||||
|
*/
|
||||||
|
router.route('/refresh-token')
|
||||||
|
.post(validate(refresh), controller.refresh);
|
||||||
|
|
||||||
|
|
||||||
|
router.route('/send-password-reset')
|
||||||
|
.post(validate(sendPasswordReset), controller.sendPasswordReset);
|
||||||
|
|
||||||
|
router.route('/reset-password')
|
||||||
|
.post(validate(passwordReset), controller.resetPassword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} v1/auth/facebook Facebook Login
|
||||||
|
* @apiDescription Login with facebook. Creates a new user if it does not exist
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName FacebookLogin
|
||||||
|
* @apiGroup Auth
|
||||||
|
* @apiPermission public
|
||||||
|
*
|
||||||
|
* @apiParam {String} access_token Facebook's access_token
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} tokenType Access Token's type
|
||||||
|
* @apiSuccess {String} accessToken Authorization Token
|
||||||
|
* @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time
|
||||||
|
* @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Incorrect access_token
|
||||||
|
*/
|
||||||
|
router.route('/facebook')
|
||||||
|
.post(validate(oAuth), oAuthLogin('facebook'), controller.oAuth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} v1/auth/google Google Login
|
||||||
|
* @apiDescription Login with google. Creates a new user if it does not exist
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName GoogleLogin
|
||||||
|
* @apiGroup Auth
|
||||||
|
* @apiPermission public
|
||||||
|
*
|
||||||
|
* @apiParam {String} access_token Google's access_token
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} tokenType Access Token's type
|
||||||
|
* @apiSuccess {String} accessToken Authorization Token
|
||||||
|
* @apiSuccess {String} refreshToken Token to get a new accpessToken after expiration time
|
||||||
|
* @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Incorrect access_token
|
||||||
|
*/
|
||||||
|
router.route('/google')
|
||||||
|
.post(validate(oAuth), oAuthLogin('google'), controller.oAuth);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
20
src/api/routes/v1/index.js
Normal file
20
src/api/routes/v1/index.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const userRoutes = require('./user.route');
|
||||||
|
const authRoutes = require('./auth.route');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET v1/status
|
||||||
|
*/
|
||||||
|
router.get('/status', (req, res) => res.send('OK'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET v1/docs
|
||||||
|
*/
|
||||||
|
router.use('/docs', express.static('docs'));
|
||||||
|
|
||||||
|
router.use('/users', userRoutes);
|
||||||
|
router.use('/auth', authRoutes);
|
||||||
|
|
||||||
|
module.exports = router;
|
193
src/api/routes/v1/user.route.js
Normal file
193
src/api/routes/v1/user.route.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const validate = require('express-validation');
|
||||||
|
const controller = require('../../controllers/user.controller');
|
||||||
|
const { authorize, ADMIN, LOGGED_USER } = require('../../middlewares/auth');
|
||||||
|
const {
|
||||||
|
listUsers,
|
||||||
|
createUser,
|
||||||
|
replaceUser,
|
||||||
|
updateUser,
|
||||||
|
} = require('../../validations/user.validation');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load user when API with userId route parameter is hit
|
||||||
|
*/
|
||||||
|
router.param('userId', controller.load);
|
||||||
|
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/')
|
||||||
|
/**
|
||||||
|
* @api {get} v1/users List Users
|
||||||
|
* @apiDescription Get a list of users
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName ListUsers
|
||||||
|
* @apiGroup User
|
||||||
|
* @apiPermission admin
|
||||||
|
*
|
||||||
|
* @apiHeader {String} Authorization User's access token
|
||||||
|
*
|
||||||
|
* @apiParam {Number{1-}} [page=1] List page
|
||||||
|
* @apiParam {Number{1-100}} [perPage=1] Users per page
|
||||||
|
* @apiParam {String} [name] User's name
|
||||||
|
* @apiParam {String} [email] User's email
|
||||||
|
* @apiParam {String=user,admin} [role] User's role
|
||||||
|
*
|
||||||
|
* @apiSuccess {Object[]} users List of users.
|
||||||
|
*
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data
|
||||||
|
* @apiError (Forbidden 403) Forbidden Only admins can access the data
|
||||||
|
*/
|
||||||
|
.get(authorize(ADMIN), validate(listUsers), controller.list)
|
||||||
|
/**
|
||||||
|
* @api {post} v1/users Create User
|
||||||
|
* @apiDescription Create a new user
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName CreateUser
|
||||||
|
* @apiGroup User
|
||||||
|
* @apiPermission admin
|
||||||
|
*
|
||||||
|
* @apiHeader {String} Authorization User's access token
|
||||||
|
*
|
||||||
|
* @apiParam {String} email User's email
|
||||||
|
* @apiParam {String{6..128}} password User's password
|
||||||
|
* @apiParam {String{..128}} [name] User's name
|
||||||
|
* @apiParam {String=user,admin} [role] User's role
|
||||||
|
*
|
||||||
|
* @apiSuccess (Created 201) {String} id User's id
|
||||||
|
* @apiSuccess (Created 201) {String} name User's name
|
||||||
|
* @apiSuccess (Created 201) {String} email User's email
|
||||||
|
* @apiSuccess (Created 201) {String} role User's role
|
||||||
|
* @apiSuccess (Created 201) {Date} createdAt Timestamp
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can create the data
|
||||||
|
* @apiError (Forbidden 403) Forbidden Only admins can create the data
|
||||||
|
*/
|
||||||
|
.post(authorize(ADMIN), validate(createUser), controller.create);
|
||||||
|
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/profile')
|
||||||
|
/**
|
||||||
|
* @api {get} v1/users/profile User Profile
|
||||||
|
* @apiDescription Get logged in user profile information
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName UserProfile
|
||||||
|
* @apiGroup User
|
||||||
|
* @apiPermission user
|
||||||
|
*
|
||||||
|
* @apiHeader {String} Authorization User's access token
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} id User's id
|
||||||
|
* @apiSuccess {String} name User's name
|
||||||
|
* @apiSuccess {String} email User's email
|
||||||
|
* @apiSuccess {String} role User's role
|
||||||
|
* @apiSuccess {Date} createdAt Timestamp
|
||||||
|
*
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Only authenticated Users can access the data
|
||||||
|
*/
|
||||||
|
.get(authorize(), controller.loggedIn);
|
||||||
|
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/:userId')
|
||||||
|
/**
|
||||||
|
* @api {get} v1/users/:id Get User
|
||||||
|
* @apiDescription Get user information
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName GetUser
|
||||||
|
* @apiGroup User
|
||||||
|
* @apiPermission user
|
||||||
|
*
|
||||||
|
* @apiHeader {String} Authorization User's access token
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} id User's id
|
||||||
|
* @apiSuccess {String} name User's name
|
||||||
|
* @apiSuccess {String} email User's email
|
||||||
|
* @apiSuccess {String} role User's role
|
||||||
|
* @apiSuccess {Date} createdAt Timestamp
|
||||||
|
*
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data
|
||||||
|
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can access the data
|
||||||
|
* @apiError (Not Found 404) NotFound User does not exist
|
||||||
|
*/
|
||||||
|
.get(authorize(LOGGED_USER), controller.get)
|
||||||
|
/**
|
||||||
|
* @api {put} v1/users/:id Replace User
|
||||||
|
* @apiDescription Replace the whole user document with a new one
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName ReplaceUser
|
||||||
|
* @apiGroup User
|
||||||
|
* @apiPermission user
|
||||||
|
*
|
||||||
|
* @apiHeader {String} Authorization User's access token
|
||||||
|
*
|
||||||
|
* @apiParam {String} email User's email
|
||||||
|
* @apiParam {String{6..128}} password User's password
|
||||||
|
* @apiParam {String{..128}} [name] User's name
|
||||||
|
* @apiParam {String=user,admin} [role] User's role
|
||||||
|
* (You must be an admin to change the user's role)
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} id User's id
|
||||||
|
* @apiSuccess {String} name User's name
|
||||||
|
* @apiSuccess {String} email User's email
|
||||||
|
* @apiSuccess {String} role User's role
|
||||||
|
* @apiSuccess {Date} createdAt Timestamp
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data
|
||||||
|
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data
|
||||||
|
* @apiError (Not Found 404) NotFound User does not exist
|
||||||
|
*/
|
||||||
|
.put(authorize(LOGGED_USER), validate(replaceUser), controller.replace)
|
||||||
|
/**
|
||||||
|
* @api {patch} v1/users/:id Update User
|
||||||
|
* @apiDescription Update some fields of a user document
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName UpdateUser
|
||||||
|
* @apiGroup User
|
||||||
|
* @apiPermission user
|
||||||
|
*
|
||||||
|
* @apiHeader {String} Authorization User's access token
|
||||||
|
*
|
||||||
|
* @apiParam {String} email User's email
|
||||||
|
* @apiParam {String{6..128}} password User's password
|
||||||
|
* @apiParam {String{..128}} [name] User's name
|
||||||
|
* @apiParam {String=user,admin} [role] User's role
|
||||||
|
* (You must be an admin to change the user's role)
|
||||||
|
*
|
||||||
|
* @apiSuccess {String} id User's id
|
||||||
|
* @apiSuccess {String} name User's name
|
||||||
|
* @apiSuccess {String} email User's email
|
||||||
|
* @apiSuccess {String} role User's role
|
||||||
|
* @apiSuccess {Date} createdAt Timestamp
|
||||||
|
*
|
||||||
|
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data
|
||||||
|
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data
|
||||||
|
* @apiError (Not Found 404) NotFound User does not exist
|
||||||
|
*/
|
||||||
|
.patch(authorize(LOGGED_USER), validate(updateUser), controller.update)
|
||||||
|
/**
|
||||||
|
* @api {patch} v1/users/:id Delete User
|
||||||
|
* @apiDescription Delete a user
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiName DeleteUser
|
||||||
|
* @apiGroup User
|
||||||
|
* @apiPermission user
|
||||||
|
*
|
||||||
|
* @apiHeader {String} Authorization User's access token
|
||||||
|
*
|
||||||
|
* @apiSuccess (No Content 204) Successfully deleted
|
||||||
|
*
|
||||||
|
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can delete the data
|
||||||
|
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can delete the data
|
||||||
|
* @apiError (Not Found 404) NotFound User does not exist
|
||||||
|
*/
|
||||||
|
.delete(authorize(LOGGED_USER), controller.remove);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
35
src/api/services/authProviders.js
Normal file
35
src/api/services/authProviders.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/* eslint-disable camelcase */
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
exports.facebook = async (access_token) => {
|
||||||
|
const fields = 'id, name, email, picture';
|
||||||
|
const url = 'https://graph.facebook.com/me';
|
||||||
|
const params = { access_token, fields };
|
||||||
|
const response = await axios.get(url, { params });
|
||||||
|
const {
|
||||||
|
id, name, email, picture,
|
||||||
|
} = response.data;
|
||||||
|
return {
|
||||||
|
service: 'facebook',
|
||||||
|
picture: picture.data.url,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.google = async (access_token) => {
|
||||||
|
const url = 'https://www.googleapis.com/oauth2/v3/userinfo';
|
||||||
|
const params = { access_token };
|
||||||
|
const response = await axios.get(url, { params });
|
||||||
|
const {
|
||||||
|
sub, name, email, picture,
|
||||||
|
} = response.data;
|
||||||
|
return {
|
||||||
|
service: 'google',
|
||||||
|
picture,
|
||||||
|
id: sub,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
};
|
77
src/api/services/emails/emailProvider.js
Normal file
77
src/api/services/emails/emailProvider.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { emailConfig } = require('../../../config/vars');
|
||||||
|
const Email = require('email-templates');
|
||||||
|
|
||||||
|
// SMTP is the main transport in Nodemailer for delivering messages.
|
||||||
|
// SMTP is also the protocol used between almost all email hosts, so its truly universal.
|
||||||
|
// if you dont want to use SMTP you can create your own transport here
|
||||||
|
// such as an email service API or nodemailer-sendgrid-transport
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
port: emailConfig.port,
|
||||||
|
host: emailConfig.host,
|
||||||
|
auth: {
|
||||||
|
user: emailConfig.username,
|
||||||
|
pass: emailConfig.password,
|
||||||
|
},
|
||||||
|
secure: false, // upgrades later with STARTTLS -- change this based on the PORT
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify connection configuration
|
||||||
|
transporter.verify((error) => {
|
||||||
|
if (error) {
|
||||||
|
console.log('error with email connection');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.sendPasswordReset = async (passwordResetObject) => {
|
||||||
|
const email = new Email({
|
||||||
|
views: { root: __dirname },
|
||||||
|
message: {
|
||||||
|
from: 'support@your-app.com',
|
||||||
|
},
|
||||||
|
// uncomment below to send emails in development/test env:
|
||||||
|
send: true,
|
||||||
|
transport: transporter,
|
||||||
|
});
|
||||||
|
|
||||||
|
email
|
||||||
|
.send({
|
||||||
|
template: 'passwordReset',
|
||||||
|
message: {
|
||||||
|
to: passwordResetObject.userEmail,
|
||||||
|
},
|
||||||
|
locals: {
|
||||||
|
productName: 'Test App',
|
||||||
|
// passwordResetUrl should be a URL to your app that displays a view where they
|
||||||
|
// can enter a new password along with passing the resetToken in the params
|
||||||
|
passwordResetUrl: `https://your-app/new-password/view?resetToken=${passwordResetObject.resetToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => console.log('error sending password reset email'));
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.sendPasswordChangeEmail = async (user) => {
|
||||||
|
const email = new Email({
|
||||||
|
views: { root: __dirname },
|
||||||
|
message: {
|
||||||
|
from: 'support@your-app.com',
|
||||||
|
},
|
||||||
|
// uncomment below to send emails in development/test env:
|
||||||
|
send: true,
|
||||||
|
transport: transporter,
|
||||||
|
});
|
||||||
|
|
||||||
|
email
|
||||||
|
.send({
|
||||||
|
template: 'passwordChange',
|
||||||
|
message: {
|
||||||
|
to: user.email,
|
||||||
|
},
|
||||||
|
locals: {
|
||||||
|
productName: 'Test App',
|
||||||
|
name: user.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => console.log('error sending change password email'));
|
||||||
|
};
|
407
src/api/services/emails/passwordChange/html.pug
Normal file
407
src/api/services/emails/passwordChange/html.pug
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
doctype transitional
|
||||||
|
head
|
||||||
|
meta(name='viewport' content='width=device-width, initial-scale=1.0')
|
||||||
|
meta(name='x-apple-disable-message-reformatting')
|
||||||
|
meta(http-equiv='Content-Type' content='text/html; charset=UTF-8')
|
||||||
|
title
|
||||||
|
style(type='text/css' rel='stylesheet' media='all').
|
||||||
|
/* Base ------------------------------ */
|
||||||
|
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #3869D4;
|
||||||
|
}
|
||||||
|
a img {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.preheader {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
mso-hide: all;
|
||||||
|
font-size: 1px;
|
||||||
|
line-height: 1px;
|
||||||
|
max-height: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Type ------------------------------ */
|
||||||
|
body,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
margin: .4em 0 1.1875em;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.625;
|
||||||
|
}
|
||||||
|
p.sub {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
/* Utilities ------------------------------ */
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* Buttons ------------------------------ */
|
||||||
|
.button {
|
||||||
|
background-color: #3869D4;
|
||||||
|
border-top: 10px solid #3869D4;
|
||||||
|
border-right: 18px solid #3869D4;
|
||||||
|
border-bottom: 10px solid #3869D4;
|
||||||
|
border-left: 18px solid #3869D4;
|
||||||
|
display: inline-block;
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.button--green {
|
||||||
|
background-color: #22BC66;
|
||||||
|
border-top: 10px solid #22BC66;
|
||||||
|
border-right: 18px solid #22BC66;
|
||||||
|
border-bottom: 10px solid #22BC66;
|
||||||
|
border-left: 18px solid #22BC66;
|
||||||
|
}
|
||||||
|
.button--red {
|
||||||
|
background-color: #FF6136;
|
||||||
|
border-top: 10px solid #FF6136;
|
||||||
|
border-right: 18px solid #FF6136;
|
||||||
|
border-bottom: 10px solid #FF6136;
|
||||||
|
border-left: 18px solid #FF6136;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 500px) {
|
||||||
|
.button {
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Attribute list ------------------------------ */
|
||||||
|
.attributes {
|
||||||
|
margin: 0 0 21px;
|
||||||
|
}
|
||||||
|
.attributes_content {
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.attributes_item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Related Items ------------------------------ */
|
||||||
|
.related {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.related_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #CBCCCF;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.related_item-title {
|
||||||
|
display: block;
|
||||||
|
margin: .5em 0 0;
|
||||||
|
}
|
||||||
|
.related_item-thumb {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.related_heading {
|
||||||
|
border-top: 1px solid #CBCCCF;
|
||||||
|
text-align: center;
|
||||||
|
padding: 25px 0 10px;
|
||||||
|
}
|
||||||
|
/* Discount Code ------------------------------ */
|
||||||
|
.discount {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
border: 2px dashed #CBCCCF;
|
||||||
|
}
|
||||||
|
.discount_heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.discount_body {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
/* Social Icons ------------------------------ */
|
||||||
|
.social {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.social td {
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.social_icon {
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 8px 10px 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Data table ------------------------------ */
|
||||||
|
.purchase {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 35px 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.purchase_content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.purchase_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #51545E;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.purchase_heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
.purchase_heading p {
|
||||||
|
margin: 0;
|
||||||
|
color: #85878E;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.purchase_footer {
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
.purchase_total {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.purchase_total--label {
|
||||||
|
padding: 0 15px 0 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.email-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.email-content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
/* Masthead ----------------------- */
|
||||||
|
.email-masthead {
|
||||||
|
padding: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-masthead_logo {
|
||||||
|
width: 94px;
|
||||||
|
}
|
||||||
|
.email-masthead_name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #A8AAAF;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 1px 0 white;
|
||||||
|
}
|
||||||
|
/* Body ------------------------------ */
|
||||||
|
.email-body {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.email-body_inner {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.email-footer {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-footer p {
|
||||||
|
color: #A8AAAF;
|
||||||
|
}
|
||||||
|
.body-action {
|
||||||
|
width: 100%;
|
||||||
|
margin: 30px auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.body-sub {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 25px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
.content-cell {
|
||||||
|
padding: 35px;
|
||||||
|
}
|
||||||
|
/*Media Queries ------------------------------ */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-body_inner,
|
||||||
|
.email-footer {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #333333 !important;
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
.attributes_content,
|
||||||
|
.discount {
|
||||||
|
background-color: #222 !important;
|
||||||
|
}
|
||||||
|
.email-masthead_name {
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//if mso
|
||||||
|
style(type='text/css').
|
||||||
|
.f-fallback {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
style(type='text/css' rel='stylesheet' media='all').
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
span.preheader(style='display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;')
|
||||||
|
| Hello #{name},
|
||||||
|
table.email-wrapper(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
|
||||||
|
tr
|
||||||
|
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
table.email-content(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
|
||||||
|
tr
|
||||||
|
td.email-masthead(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;' align='center')
|
||||||
|
a.f-fallback.email-masthead_name(href='https://example.com' style='color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;')
|
||||||
|
| #{productName}
|
||||||
|
// Email Body
|
||||||
|
tr
|
||||||
|
td.email-body(width='570' cellpadding='0' cellspacing='0' style='word-break: break-word; margin: 0; padding: 0; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0;')
|
||||||
|
table.email-body_inner(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0 auto; padding: 0;')
|
||||||
|
// Body content
|
||||||
|
tr
|
||||||
|
td.content-cell(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
|
||||||
|
.f-fallback
|
||||||
|
h1(style='margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;' align='left') Hello,
|
||||||
|
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
|
||||||
|
| Your password for #{productName} has been changed successfully. You can now login with your new password
|
||||||
|
// Action
|
||||||
|
table.body-action(align='center' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;')
|
||||||
|
tr
|
||||||
|
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
//
|
||||||
|
Border based button
|
||||||
|
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design
|
||||||
|
table(width='100%' border='0' cellspacing='0' cellpadding='0' role='presentation')
|
||||||
|
tr
|
||||||
|
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
|
||||||
|
tr
|
||||||
|
td(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
table.email-footer(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 0 auto; padding: 0;')
|
||||||
|
tr
|
||||||
|
td.content-cell(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
|
||||||
|
p.f-fallback.sub.align-center(style='font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;' align='center') © 2019 #{productName}. All rights reserved.
|
1
src/api/services/emails/passwordChange/subject.pug
Normal file
1
src/api/services/emails/passwordChange/subject.pug
Normal file
@ -0,0 +1 @@
|
|||||||
|
= `${productName} Password Changed`
|
436
src/api/services/emails/passwordReset/html.pug
Normal file
436
src/api/services/emails/passwordReset/html.pug
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
doctype transitional
|
||||||
|
head
|
||||||
|
meta(name='viewport' content='width=device-width, initial-scale=1.0')
|
||||||
|
meta(name='x-apple-disable-message-reformatting')
|
||||||
|
meta(http-equiv='Content-Type' content='text/html; charset=UTF-8')
|
||||||
|
title
|
||||||
|
style(type='text/css' rel='stylesheet' media='all').
|
||||||
|
/* Base ------------------------------ */
|
||||||
|
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #3869D4;
|
||||||
|
}
|
||||||
|
a img {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.preheader {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
mso-hide: all;
|
||||||
|
font-size: 1px;
|
||||||
|
line-height: 1px;
|
||||||
|
max-height: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Type ------------------------------ */
|
||||||
|
body,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
margin: .4em 0 1.1875em;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.625;
|
||||||
|
}
|
||||||
|
p.sub {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
/* Utilities ------------------------------ */
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* Buttons ------------------------------ */
|
||||||
|
.button {
|
||||||
|
background-color: #3869D4;
|
||||||
|
border-top: 10px solid #3869D4;
|
||||||
|
border-right: 18px solid #3869D4;
|
||||||
|
border-bottom: 10px solid #3869D4;
|
||||||
|
border-left: 18px solid #3869D4;
|
||||||
|
display: inline-block;
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.button--green {
|
||||||
|
background-color: #22BC66;
|
||||||
|
border-top: 10px solid #22BC66;
|
||||||
|
border-right: 18px solid #22BC66;
|
||||||
|
border-bottom: 10px solid #22BC66;
|
||||||
|
border-left: 18px solid #22BC66;
|
||||||
|
}
|
||||||
|
.button--red {
|
||||||
|
background-color: #FF6136;
|
||||||
|
border-top: 10px solid #FF6136;
|
||||||
|
border-right: 18px solid #FF6136;
|
||||||
|
border-bottom: 10px solid #FF6136;
|
||||||
|
border-left: 18px solid #FF6136;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 500px) {
|
||||||
|
.button {
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Attribute list ------------------------------ */
|
||||||
|
.attributes {
|
||||||
|
margin: 0 0 21px;
|
||||||
|
}
|
||||||
|
.attributes_content {
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.attributes_item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Related Items ------------------------------ */
|
||||||
|
.related {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.related_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #CBCCCF;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.related_item-title {
|
||||||
|
display: block;
|
||||||
|
margin: .5em 0 0;
|
||||||
|
}
|
||||||
|
.related_item-thumb {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.related_heading {
|
||||||
|
border-top: 1px solid #CBCCCF;
|
||||||
|
text-align: center;
|
||||||
|
padding: 25px 0 10px;
|
||||||
|
}
|
||||||
|
/* Discount Code ------------------------------ */
|
||||||
|
.discount {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
border: 2px dashed #CBCCCF;
|
||||||
|
}
|
||||||
|
.discount_heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.discount_body {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
/* Social Icons ------------------------------ */
|
||||||
|
.social {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.social td {
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.social_icon {
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 8px 10px 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Data table ------------------------------ */
|
||||||
|
.purchase {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 35px 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.purchase_content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.purchase_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #51545E;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.purchase_heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
.purchase_heading p {
|
||||||
|
margin: 0;
|
||||||
|
color: #85878E;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.purchase_footer {
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
.purchase_total {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.purchase_total--label {
|
||||||
|
padding: 0 15px 0 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.email-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.email-content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
/* Masthead ----------------------- */
|
||||||
|
.email-masthead {
|
||||||
|
padding: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-masthead_logo {
|
||||||
|
width: 94px;
|
||||||
|
}
|
||||||
|
.email-masthead_name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #A8AAAF;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 1px 0 white;
|
||||||
|
}
|
||||||
|
/* Body ------------------------------ */
|
||||||
|
.email-body {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.email-body_inner {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
.email-footer {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-footer p {
|
||||||
|
color: #A8AAAF;
|
||||||
|
}
|
||||||
|
.body-action {
|
||||||
|
width: 100%;
|
||||||
|
margin: 30px auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.body-sub {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 25px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
.content-cell {
|
||||||
|
padding: 35px;
|
||||||
|
}
|
||||||
|
/*Media Queries ------------------------------ */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-body_inner,
|
||||||
|
.email-footer {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #333333 !important;
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
.attributes_content,
|
||||||
|
.discount {
|
||||||
|
background-color: #222 !important;
|
||||||
|
}
|
||||||
|
.email-masthead_name {
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//if mso
|
||||||
|
style(type='text/css').
|
||||||
|
.f-fallback {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
style(type='text/css' rel='stylesheet' media='all').
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
span.preheader(style='display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;')
|
||||||
|
| Use
|
||||||
|
| this link to reset your password. The link is only valid for 2 hours.
|
||||||
|
table.email-wrapper(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
|
||||||
|
tr
|
||||||
|
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
table.email-content(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
|
||||||
|
tr
|
||||||
|
td.email-masthead(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;' align='center')
|
||||||
|
a.f-fallback.email-masthead_name(href='https://example.com' style='color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;')
|
||||||
|
#{productName}
|
||||||
|
// Email Body
|
||||||
|
tr
|
||||||
|
td.email-body(width='570' cellpadding='0' cellspacing='0' style='word-break: break-word; margin: 0; padding: 0; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0;')
|
||||||
|
table.email-body_inner(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0 auto; padding: 0;')
|
||||||
|
// Body content
|
||||||
|
tr
|
||||||
|
td.content-cell(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
|
||||||
|
.f-fallback
|
||||||
|
h1(style='margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;' align='left') Hi,
|
||||||
|
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
|
||||||
|
| You recently requested to reset your password for your #{productName}
|
||||||
|
| account. Use the button below to reset it.
|
||||||
|
strong
|
||||||
|
| This password reset
|
||||||
|
| is only valid for the next 2 hours.
|
||||||
|
// Action
|
||||||
|
table.body-action(align='center' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;')
|
||||||
|
tr
|
||||||
|
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
//
|
||||||
|
Border based button
|
||||||
|
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design
|
||||||
|
table(width='100%' border='0' cellspacing='0' cellpadding='0' role='presentation')
|
||||||
|
tr
|
||||||
|
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
a.f-fallback.button.button--green(href='#{passwordResetUrl}' target='_blank' style='color: #FFF; border-color: #22bc66; border-style: solid; border-width: 10px 18px; background-color: #22BC66; display: inline-block; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box;')
|
||||||
|
| Reset
|
||||||
|
| your password
|
||||||
|
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
|
||||||
|
| If you did not request a password reset,
|
||||||
|
| please ignore this email.
|
||||||
|
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
|
||||||
|
| Thanks,
|
||||||
|
br
|
||||||
|
| The #{productName} Team
|
||||||
|
// Sub copy
|
||||||
|
table.body-sub(role='presentation' style='margin-top: 25px; padding-top: 25px; border-top-width: 1px; border-top-color: #EAEAEC; border-top-style: solid;')
|
||||||
|
tr
|
||||||
|
td(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
p.f-fallback.sub(style='font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
|
||||||
|
| If you’re having trouble with the button above, copy and
|
||||||
|
| paste the URL below into your web browser.
|
||||||
|
p.f-fallback.sub(style='font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
|
||||||
|
| #{passwordResetUrl}
|
||||||
|
tr
|
||||||
|
td(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
|
||||||
|
table.email-footer(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 0 auto; padding: 0;')
|
||||||
|
tr
|
||||||
|
td.content-cell(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
|
||||||
|
p.f-fallback.sub.align-center(style='font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;' align='center') © 2019 [Product Name]. All rights reserved.
|
||||||
|
p.f-fallback.sub.align-center(style='font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;' align='center')
|
||||||
|
| [Company Name, LLC]
|
||||||
|
br
|
||||||
|
| 1234 Street Rd.
|
||||||
|
br
|
||||||
|
| Suite 1234
|
1
src/api/services/emails/passwordReset/subject.pug
Normal file
1
src/api/services/emails/passwordReset/subject.pug
Normal file
@ -0,0 +1 @@
|
|||||||
|
= `${productName} Password Reset`
|
528
src/api/tests/integration/auth.test.js
Normal file
528
src/api/tests/integration/auth.test.js
Normal file
@ -0,0 +1,528 @@
|
|||||||
|
/* eslint-disable arrow-body-style */
|
||||||
|
const request = require('supertest');
|
||||||
|
const httpStatus = require('http-status');
|
||||||
|
const { expect } = require('chai');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
const moment = require('moment-timezone');
|
||||||
|
const app = require('../../../index');
|
||||||
|
const User = require('../../models/user.model');
|
||||||
|
const RefreshToken = require('../../models/refreshToken.model');
|
||||||
|
const PasswordResetToken = require('../../models/passwordResetToken.model');
|
||||||
|
const authProviders = require('../../services/authProviders');
|
||||||
|
const emailProvider = require('../../services/emails/emailProvider');
|
||||||
|
|
||||||
|
const sandbox = sinon.createSandbox();
|
||||||
|
|
||||||
|
const fakeOAuthRequest = () =>
|
||||||
|
Promise.resolve({
|
||||||
|
service: 'facebook',
|
||||||
|
id: '123',
|
||||||
|
name: 'user',
|
||||||
|
email: 'test@test.com',
|
||||||
|
picture: 'test.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication API', () => {
|
||||||
|
let dbUser;
|
||||||
|
let user;
|
||||||
|
let refreshToken;
|
||||||
|
let resetToken;
|
||||||
|
let expiredRefreshToken;
|
||||||
|
let expiredResetToken;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dbUser = {
|
||||||
|
email: 'branstark@gmail.com',
|
||||||
|
password: 'mypassword',
|
||||||
|
name: 'Bran Stark',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
user = {
|
||||||
|
email: 'krish@gmail.com',
|
||||||
|
password: '123456',
|
||||||
|
name: 'krish Sousa',
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshToken = {
|
||||||
|
token:
|
||||||
|
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
|
||||||
|
userId: '5947397b323ae82d8c3a333b',
|
||||||
|
userEmail: dbUser.email,
|
||||||
|
expires: moment()
|
||||||
|
.add(1, 'day')
|
||||||
|
.toDate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
resetToken = {
|
||||||
|
resetToken:
|
||||||
|
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
|
||||||
|
userId: '5947397b323ae82d8c3a333b',
|
||||||
|
userEmail: dbUser.email,
|
||||||
|
expires: moment()
|
||||||
|
.add(2, 'hours')
|
||||||
|
.toDate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expiredRefreshToken = {
|
||||||
|
token:
|
||||||
|
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
|
||||||
|
userId: '5947397b323ae82d8c3a333b',
|
||||||
|
userEmail: dbUser.email,
|
||||||
|
expires: moment()
|
||||||
|
.subtract(1, 'day')
|
||||||
|
.toDate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expiredResetToken = {
|
||||||
|
resetToken:
|
||||||
|
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
|
||||||
|
userId: '5947397b323ae82d8c3a333b',
|
||||||
|
userEmail: dbUser.email,
|
||||||
|
expires: moment()
|
||||||
|
.subtract(2, 'hours')
|
||||||
|
.toDate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await User.deleteMany({});
|
||||||
|
await User.create(dbUser);
|
||||||
|
await RefreshToken.deleteMany({});
|
||||||
|
await PasswordResetToken.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => sandbox.restore());
|
||||||
|
|
||||||
|
describe('POST /v1/auth/register', () => {
|
||||||
|
it('should register a new user when request is ok', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/register')
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.CREATED)
|
||||||
|
.then((res) => {
|
||||||
|
delete user.password;
|
||||||
|
expect(res.body.token).to.have.a.property('accessToken');
|
||||||
|
expect(res.body.token).to.have.a.property('refreshToken');
|
||||||
|
expect(res.body.token).to.have.a.property('expiresIn');
|
||||||
|
expect(res.body.user).to.include(user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email already exists', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/register')
|
||||||
|
.send(dbUser)
|
||||||
|
.expect(httpStatus.CONFLICT)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" already exists');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when the email provided is not valid', () => {
|
||||||
|
user.email = 'this_is_not_an_email';
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/register')
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" must be a valid email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email and password are not provided', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/register')
|
||||||
|
.send({})
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/auth/login', () => {
|
||||||
|
it('should return an accessToken and a refreshToken when email and password matches', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/login')
|
||||||
|
.send(dbUser)
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
delete dbUser.password;
|
||||||
|
expect(res.body.token).to.have.a.property('accessToken');
|
||||||
|
expect(res.body.token).to.have.a.property('refreshToken');
|
||||||
|
expect(res.body.token).to.have.a.property('expiresIn');
|
||||||
|
expect(res.body.user).to.include(dbUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email and password are not provided', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/login')
|
||||||
|
.send({})
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when the email provided is not valid', () => {
|
||||||
|
user.email = 'this_is_not_an_email';
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/login')
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" must be a valid email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report error when email and password don't match", () => {
|
||||||
|
dbUser.password = 'xxx';
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/login')
|
||||||
|
.send(dbUser)
|
||||||
|
.expect(httpStatus.UNAUTHORIZED)
|
||||||
|
.then((res) => {
|
||||||
|
const { code } = res.body;
|
||||||
|
const { message } = res.body;
|
||||||
|
expect(code).to.be.equal(401);
|
||||||
|
expect(message).to.be.equal('Incorrect email or password');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/auth/facebook', () => {
|
||||||
|
it('should create a new user and return an accessToken when user does not exist', () => {
|
||||||
|
sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/facebook')
|
||||||
|
.send({ access_token: '123' })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.token).to.have.a.property('accessToken');
|
||||||
|
expect(res.body.token).to.have.a.property('refreshToken');
|
||||||
|
expect(res.body.token).to.have.a.property('expiresIn');
|
||||||
|
expect(res.body.user).to.be.an('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an accessToken when user already exists', async () => {
|
||||||
|
dbUser.email = 'test@test.com';
|
||||||
|
await User.create(dbUser);
|
||||||
|
sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/facebook')
|
||||||
|
.send({ access_token: '123' })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.token).to.have.a.property('accessToken');
|
||||||
|
expect(res.body.token).to.have.a.property('refreshToken');
|
||||||
|
expect(res.body.token).to.have.a.property('expiresIn');
|
||||||
|
expect(res.body.user).to.be.an('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when access_token is not provided', async () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/facebook')
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('access_token');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"access_token" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/auth/google', () => {
|
||||||
|
it('should create a new user and return an accessToken when user does not exist', () => {
|
||||||
|
sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/google')
|
||||||
|
.send({ access_token: '123' })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.token).to.have.a.property('accessToken');
|
||||||
|
expect(res.body.token).to.have.a.property('refreshToken');
|
||||||
|
expect(res.body.token).to.have.a.property('expiresIn');
|
||||||
|
expect(res.body.user).to.be.an('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an accessToken when user already exists', async () => {
|
||||||
|
dbUser.email = 'test@test.com';
|
||||||
|
await User.create(dbUser);
|
||||||
|
sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/google')
|
||||||
|
.send({ access_token: '123' })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.token).to.have.a.property('accessToken');
|
||||||
|
expect(res.body.token).to.have.a.property('refreshToken');
|
||||||
|
expect(res.body.token).to.have.a.property('expiresIn');
|
||||||
|
expect(res.body.user).to.be.an('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when access_token is not provided', async () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/google')
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('access_token');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"access_token" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/auth/refresh-token', () => {
|
||||||
|
it('should return a new accessToken when refreshToken and email match', async () => {
|
||||||
|
await RefreshToken.create(refreshToken);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/refresh-token')
|
||||||
|
.send({ email: dbUser.email, refreshToken: refreshToken.token })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).to.have.a.property('accessToken');
|
||||||
|
expect(res.body).to.have.a.property('refreshToken');
|
||||||
|
expect(res.body).to.have.a.property('expiresIn');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report error when email and refreshToken don't match", async () => {
|
||||||
|
await RefreshToken.create(refreshToken);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/refresh-token')
|
||||||
|
.send({ email: user.email, refreshToken: refreshToken.token })
|
||||||
|
.expect(httpStatus.UNAUTHORIZED)
|
||||||
|
.then((res) => {
|
||||||
|
const { code } = res.body;
|
||||||
|
const { message } = res.body;
|
||||||
|
expect(code).to.be.equal(401);
|
||||||
|
expect(message).to.be.equal('Incorrect email or refreshToken');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email and refreshToken are not provided', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/refresh-token')
|
||||||
|
.send({})
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const field1 = res.body.errors[0].field;
|
||||||
|
const location1 = res.body.errors[0].location;
|
||||||
|
const messages1 = res.body.errors[0].messages;
|
||||||
|
const field2 = res.body.errors[1].field;
|
||||||
|
const location2 = res.body.errors[1].location;
|
||||||
|
const messages2 = res.body.errors[1].messages;
|
||||||
|
expect(field1).to.be.equal('email');
|
||||||
|
expect(location1).to.be.equal('body');
|
||||||
|
expect(messages1).to.include('"email" is required');
|
||||||
|
expect(field2).to.be.equal('refreshToken');
|
||||||
|
expect(location2).to.be.equal('body');
|
||||||
|
expect(messages2).to.include('"refreshToken" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when the refreshToken is expired', async () => {
|
||||||
|
await RefreshToken.create(expiredRefreshToken);
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/refresh-token')
|
||||||
|
.send({ email: dbUser.email, refreshToken: expiredRefreshToken.token })
|
||||||
|
.expect(httpStatus.UNAUTHORIZED)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(401);
|
||||||
|
expect(res.body.message).to.be.equal('Invalid refresh token.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('POST /v1/auth/send-password-reset', () => {
|
||||||
|
it('should send an email with password reset link when email matches a user', async () => {
|
||||||
|
const PasswordResetTokenObj = await PasswordResetToken.create(resetToken);
|
||||||
|
|
||||||
|
expect(PasswordResetTokenObj.resetToken).to.be.equal('5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d');
|
||||||
|
expect(PasswordResetTokenObj.userId.toString()).to.be.equal('5947397b323ae82d8c3a333b');
|
||||||
|
expect(PasswordResetTokenObj.userEmail).to.be.equal(dbUser.email);
|
||||||
|
expect(PasswordResetTokenObj.expires).to.be.above(moment()
|
||||||
|
.add(1, 'hour')
|
||||||
|
.toDate());
|
||||||
|
|
||||||
|
sandbox
|
||||||
|
.stub(emailProvider, 'sendPasswordReset')
|
||||||
|
.callsFake(() => Promise.resolve('email sent'));
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/send-password-reset')
|
||||||
|
.send({ email: dbUser.email })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).to.be.equal('success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report error when email doesn't match a user", async () => {
|
||||||
|
await PasswordResetToken.create(resetToken);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/send-password-reset')
|
||||||
|
.send({ email: user.email })
|
||||||
|
.expect(httpStatus.UNAUTHORIZED)
|
||||||
|
.then((res) => {
|
||||||
|
const { code } = res.body;
|
||||||
|
const { message } = res.body;
|
||||||
|
expect(code).to.be.equal(httpStatus.UNAUTHORIZED);
|
||||||
|
expect(message).to.be.equal('No account found with that email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email is not provided', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/send-password-reset')
|
||||||
|
.send({})
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const field1 = res.body.errors[0].field;
|
||||||
|
const location1 = res.body.errors[0].location;
|
||||||
|
const messages1 = res.body.errors[0].messages;
|
||||||
|
expect(field1).to.be.equal('email');
|
||||||
|
expect(location1).to.be.equal('body');
|
||||||
|
expect(messages1).to.include('"email" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('POST /v1/auth/reset-password', () => {
|
||||||
|
it('should update password and send confirmation email when email and reset token are valid', async () => {
|
||||||
|
await PasswordResetToken.create(resetToken);
|
||||||
|
|
||||||
|
sandbox
|
||||||
|
.stub(emailProvider, 'sendPasswordChangeEmail')
|
||||||
|
.callsFake(() => Promise.resolve('email sent'));
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/reset-password')
|
||||||
|
.send({
|
||||||
|
email: dbUser.email,
|
||||||
|
password: 'updatedPassword2',
|
||||||
|
resetToken: resetToken.resetToken,
|
||||||
|
})
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).to.be.equal('Password Updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("should report error when email and reset token doesn't match a user", async () => {
|
||||||
|
await PasswordResetToken.create(resetToken);
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/reset-password')
|
||||||
|
.send({
|
||||||
|
email: user.email,
|
||||||
|
password: 'updatedPassword',
|
||||||
|
resetToken: resetToken.resetToken,
|
||||||
|
})
|
||||||
|
.expect(httpStatus.UNAUTHORIZED)
|
||||||
|
.then((res) => {
|
||||||
|
const { code } = res.body;
|
||||||
|
const { message } = res.body;
|
||||||
|
expect(code).to.be.equal(401);
|
||||||
|
expect(message).to.be.equal('Cannot find matching reset token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email is not provided', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/reset-password')
|
||||||
|
.send({ password: 'updatedPassword', resetToken: resetToken.resetToken })
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const field1 = res.body.errors[0].field;
|
||||||
|
const location1 = res.body.errors[0].location;
|
||||||
|
const messages1 = res.body.errors[0].messages;
|
||||||
|
expect(field1).to.be.equal('email');
|
||||||
|
expect(location1).to.be.equal('body');
|
||||||
|
expect(messages1).to.include('"email" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should report error when reset token is not provided', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/reset-password')
|
||||||
|
.send({ email: dbUser.email, password: 'updatedPassword' })
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const field1 = res.body.errors[0].field;
|
||||||
|
const location1 = res.body.errors[0].location;
|
||||||
|
const messages1 = res.body.errors[0].messages;
|
||||||
|
expect(field1).to.be.equal('resetToken');
|
||||||
|
expect(location1).to.be.equal('body');
|
||||||
|
expect(messages1).to.include('"resetToken" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should report error when password is not provided', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/reset-password')
|
||||||
|
.send({ email: dbUser.email, resetToken: resetToken.resetToken })
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const field1 = res.body.errors[0].field;
|
||||||
|
const location1 = res.body.errors[0].location;
|
||||||
|
const messages1 = res.body.errors[0].messages;
|
||||||
|
expect(field1).to.be.equal('password');
|
||||||
|
expect(location1).to.be.equal('body');
|
||||||
|
expect(messages1).to.include('"password" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when the resetToken is expired', async () => {
|
||||||
|
const expiredPasswordResetTokenObj = await PasswordResetToken.create(expiredResetToken);
|
||||||
|
|
||||||
|
expect(expiredPasswordResetTokenObj.resetToken).to.be.equal('5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d');
|
||||||
|
expect(expiredPasswordResetTokenObj.userId.toString()).to.be.equal('5947397b323ae82d8c3a333b');
|
||||||
|
expect(expiredPasswordResetTokenObj.userEmail).to.be.equal(dbUser.email);
|
||||||
|
expect(expiredPasswordResetTokenObj.expires).to.be.below(moment().toDate());
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/auth/reset-password')
|
||||||
|
.send({
|
||||||
|
email: dbUser.email,
|
||||||
|
password: 'updated password',
|
||||||
|
resetToken: expiredResetToken.resetToken,
|
||||||
|
})
|
||||||
|
.expect(httpStatus.UNAUTHORIZED)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(401);
|
||||||
|
expect(res.body.message).to.include('Reset token is expired');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
550
src/api/tests/integration/user.test.js
Normal file
550
src/api/tests/integration/user.test.js
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
/* eslint-disable arrow-body-style */
|
||||||
|
/* eslint-disable no-unused-expressions */
|
||||||
|
const request = require('supertest');
|
||||||
|
const httpStatus = require('http-status');
|
||||||
|
const { expect } = require('chai');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { some, omitBy, isNil } = require('lodash');
|
||||||
|
const app = require('../../../index');
|
||||||
|
const User = require('../../models/user.model');
|
||||||
|
const JWT_EXPIRATION = require('../../../config/vars').jwtExpirationInterval;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* root level hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function format(user) {
|
||||||
|
const formated = user;
|
||||||
|
|
||||||
|
// delete password
|
||||||
|
delete formated.password;
|
||||||
|
|
||||||
|
// get users from database
|
||||||
|
const dbUser = (await User.findOne({ email: user.email })).transform();
|
||||||
|
|
||||||
|
// remove null and undefined properties
|
||||||
|
return omitBy(dbUser, isNil);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Users API', async () => {
|
||||||
|
let adminAccessToken;
|
||||||
|
let userAccessToken;
|
||||||
|
let dbUsers;
|
||||||
|
let user;
|
||||||
|
let admin;
|
||||||
|
|
||||||
|
const password = '123456';
|
||||||
|
const passwordHashed = await bcrypt.hash(password, 1);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dbUsers = {
|
||||||
|
branStark: {
|
||||||
|
email: 'branstark@gmail.com',
|
||||||
|
password: passwordHashed,
|
||||||
|
name: 'Bran Stark',
|
||||||
|
role: 'admin',
|
||||||
|
},
|
||||||
|
jonSnow: {
|
||||||
|
email: 'jonsnow@gmail.com',
|
||||||
|
password: passwordHashed,
|
||||||
|
name: 'Jon Snow',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
user = {
|
||||||
|
email: 'sousa.dfs@gmail.com',
|
||||||
|
password,
|
||||||
|
name: 'krish Sousa',
|
||||||
|
};
|
||||||
|
|
||||||
|
admin = {
|
||||||
|
email: 'sousa.dfs@gmail.com',
|
||||||
|
password,
|
||||||
|
name: 'krish Sousa',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
await User.deleteMany({});
|
||||||
|
await User.insertMany([dbUsers.branStark, dbUsers.jonSnow]);
|
||||||
|
dbUsers.branStark.password = password;
|
||||||
|
dbUsers.jonSnow.password = password;
|
||||||
|
adminAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken;
|
||||||
|
userAccessToken = (await User.findAndGenerateToken(dbUsers.jonSnow)).accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/users', () => {
|
||||||
|
it('should create a new user when request is ok', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(admin)
|
||||||
|
.expect(httpStatus.CREATED)
|
||||||
|
.then((res) => {
|
||||||
|
delete admin.password;
|
||||||
|
expect(res.body).to.include(admin);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new user and set default role to "user"', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.CREATED)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.role).to.be.equal('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email already exists', () => {
|
||||||
|
user.email = dbUsers.branStark.email;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.CONFLICT)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" already exists');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email is not provided', () => {
|
||||||
|
delete user.email;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when password length is less than 6', () => {
|
||||||
|
user.password = '12345';
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('password');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"password" length must be at least 6 characters long');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when logged user is not an admin', () => {
|
||||||
|
return request(app)
|
||||||
|
.post('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.FORBIDDEN)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
|
||||||
|
expect(res.body.message).to.be.equal('Forbidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /v1/users', () => {
|
||||||
|
it('should get all users', () => {
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then(async (res) => {
|
||||||
|
const bran = await format(dbUsers.branStark);
|
||||||
|
const john = await format(dbUsers.jonSnow);
|
||||||
|
// before comparing it is necessary to convert String to Date
|
||||||
|
res.body[0].createdAt = new Date(res.body[0].createdAt);
|
||||||
|
res.body[1].createdAt = new Date(res.body[1].createdAt);
|
||||||
|
|
||||||
|
const includesBranStark = some(res.body, bran);
|
||||||
|
const includesjonSnow = some(res.body, john);
|
||||||
|
|
||||||
|
expect(res.body).to.be.an('array');
|
||||||
|
expect(res.body).to.have.lengthOf(2);
|
||||||
|
expect(includesBranStark).to.be.true;
|
||||||
|
expect(includesjonSnow).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all users with pagination', () => {
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.query({ page: 2, perPage: 1 })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then(async (res) => {
|
||||||
|
delete dbUsers.jonSnow.password;
|
||||||
|
|
||||||
|
expect(res.body).to.be.an('array');
|
||||||
|
expect(res.body[0]).to.be.an('object');
|
||||||
|
expect(res.body).to.have.lengthOf(1);
|
||||||
|
expect(res.body[0].name).to.be.equal('Jon Snow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter users', () => {
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.query({ email: dbUsers.jonSnow.email })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then(async (res) => {
|
||||||
|
delete dbUsers.jonSnow.password;
|
||||||
|
const john = await format(dbUsers.jonSnow);
|
||||||
|
|
||||||
|
// before comparing it is necessary to convert String to Date
|
||||||
|
res.body[0].createdAt = new Date(res.body[0].createdAt);
|
||||||
|
|
||||||
|
const includesjonSnow = some(res.body, john);
|
||||||
|
|
||||||
|
expect(res.body).to.be.an('array');
|
||||||
|
expect(res.body).to.have.lengthOf(1);
|
||||||
|
expect(includesjonSnow).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when pagination\'s parameters are not a number', () => {
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.query({ page: '?', perPage: 'whaat' })
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('page');
|
||||||
|
expect(location).to.be.equal('query');
|
||||||
|
expect(messages).to.include('"page" must be a number');
|
||||||
|
return Promise.resolve(res);
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[1];
|
||||||
|
const { location } = res.body.errors[1];
|
||||||
|
const { messages } = res.body.errors[1];
|
||||||
|
expect(field).to.be.equal('perPage');
|
||||||
|
expect(location).to.be.equal('query');
|
||||||
|
expect(messages).to.include('"perPage" must be a number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error if logged user is not an admin', () => {
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.expect(httpStatus.FORBIDDEN)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
|
||||||
|
expect(res.body.message).to.be.equal('Forbidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /v1/users/:userId', () => {
|
||||||
|
it('should get user', async () => {
|
||||||
|
const id = (await User.findOne({}))._id;
|
||||||
|
delete dbUsers.branStark.password;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.get(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).to.include(dbUsers.branStark);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error "User does not exist" when user does not exists', () => {
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users/56c787ccc67fc16ccc1a5e92')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.NOT_FOUND)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(404);
|
||||||
|
expect(res.body.message).to.be.equal('User does not exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error "User does not exist" when id is not a valid ObjectID', () => {
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users/palmeiras1914')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.NOT_FOUND)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(404);
|
||||||
|
expect(res.body.message).to.equal('User does not exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when logged user is not the same as the requested one', async () => {
|
||||||
|
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.get(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.expect(httpStatus.FORBIDDEN)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
|
||||||
|
expect(res.body.message).to.be.equal('Forbidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /v1/users/:userId', () => {
|
||||||
|
it('should replace user', async () => {
|
||||||
|
delete dbUsers.branStark.password;
|
||||||
|
const id = (await User.findOne(dbUsers.branStark))._id;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.put(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
delete user.password;
|
||||||
|
expect(res.body).to.include(user);
|
||||||
|
expect(res.body.role).to.be.equal('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when email is not provided', async () => {
|
||||||
|
const id = (await User.findOne({}))._id;
|
||||||
|
delete user.email;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.put(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('email');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"email" is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error user when password length is less than 6', async () => {
|
||||||
|
const id = (await User.findOne({}))._id;
|
||||||
|
user.password = '12345';
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.put(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send(user)
|
||||||
|
.expect(httpStatus.BAD_REQUEST)
|
||||||
|
.then((res) => {
|
||||||
|
const { field } = res.body.errors[0];
|
||||||
|
const { location } = res.body.errors[0];
|
||||||
|
const { messages } = res.body.errors[0];
|
||||||
|
expect(field).to.be.equal('password');
|
||||||
|
expect(location).to.be.equal('body');
|
||||||
|
expect(messages).to.include('"password" length must be at least 6 characters long');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error "User does not exist" when user does not exists', () => {
|
||||||
|
return request(app)
|
||||||
|
.put('/v1/users/palmeiras1914')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.NOT_FOUND)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(404);
|
||||||
|
expect(res.body.message).to.be.equal('User does not exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when logged user is not the same as the requested one', async () => {
|
||||||
|
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.put(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.expect(httpStatus.FORBIDDEN)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
|
||||||
|
expect(res.body.message).to.be.equal('Forbidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not replace the role of the user (not admin)', async () => {
|
||||||
|
const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id;
|
||||||
|
const role = 'admin';
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.put(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.send(admin)
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.role).to.not.be.equal(role);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /v1/users/:userId', () => {
|
||||||
|
it('should update user', async () => {
|
||||||
|
delete dbUsers.branStark.password;
|
||||||
|
const id = (await User.findOne(dbUsers.branStark))._id;
|
||||||
|
const { name } = user;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.patch(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.name).to.be.equal(name);
|
||||||
|
expect(res.body.email).to.be.equal(dbUsers.branStark.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update user when no parameters were given', async () => {
|
||||||
|
delete dbUsers.branStark.password;
|
||||||
|
const id = (await User.findOne(dbUsers.branStark))._id;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.patch(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send()
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).to.include(dbUsers.branStark);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error "User does not exist" when user does not exists', () => {
|
||||||
|
return request(app)
|
||||||
|
.patch('/v1/users/palmeiras1914')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.NOT_FOUND)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(404);
|
||||||
|
expect(res.body.message).to.be.equal('User does not exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when logged user is not the same as the requested one', async () => {
|
||||||
|
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.patch(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.expect(httpStatus.FORBIDDEN)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
|
||||||
|
expect(res.body.message).to.be.equal('Forbidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update the role of the user (not admin)', async () => {
|
||||||
|
const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id;
|
||||||
|
const role = 'admin';
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.patch(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.send({ role })
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.role).to.not.be.equal(role);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /v1/users', () => {
|
||||||
|
it('should delete user', async () => {
|
||||||
|
const id = (await User.findOne({}))._id;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.delete(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.NO_CONTENT)
|
||||||
|
.then(() => request(app).get('/v1/users'))
|
||||||
|
.then(async () => {
|
||||||
|
const users = await User.find({});
|
||||||
|
expect(users).to.have.lengthOf(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error "User does not exist" when user does not exists', () => {
|
||||||
|
return request(app)
|
||||||
|
.delete('/v1/users/palmeiras1914')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(httpStatus.NOT_FOUND)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(404);
|
||||||
|
expect(res.body.message).to.be.equal('User does not exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error when logged user is not the same as the requested one', async () => {
|
||||||
|
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.delete(`/v1/users/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.expect(httpStatus.FORBIDDEN)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
|
||||||
|
expect(res.body.message).to.be.equal('Forbidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /v1/users/profile', () => {
|
||||||
|
it('should get the logged user\'s info', () => {
|
||||||
|
delete dbUsers.jonSnow.password;
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users/profile')
|
||||||
|
.set('Authorization', `Bearer ${userAccessToken}`)
|
||||||
|
.expect(httpStatus.OK)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).to.include(dbUsers.jonSnow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report error without stacktrace when accessToken is expired', async () => {
|
||||||
|
// fake time
|
||||||
|
const clock = sinon.useFakeTimers();
|
||||||
|
const expiredAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken;
|
||||||
|
|
||||||
|
// move clock forward by minutes set in config + 1 minute
|
||||||
|
clock.tick((JWT_EXPIRATION * 60000) + 60000);
|
||||||
|
|
||||||
|
return request(app)
|
||||||
|
.get('/v1/users/profile')
|
||||||
|
.set('Authorization', `Bearer ${expiredAccessToken}`)
|
||||||
|
.expect(httpStatus.UNAUTHORIZED)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.code).to.be.equal(httpStatus.UNAUTHORIZED);
|
||||||
|
expect(res.body.message).to.be.equal('jwt expired');
|
||||||
|
expect(res.body).to.not.have.a.property('stack');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
46
src/api/utils/APIError.js
Normal file
46
src/api/utils/APIError.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
const httpStatus = require('http-status');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Error
|
||||||
|
*/
|
||||||
|
class ExtendableError extends Error {
|
||||||
|
constructor({
|
||||||
|
message, errors, status, isPublic, stack,
|
||||||
|
}) {
|
||||||
|
super(message);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.message = message;
|
||||||
|
this.errors = errors;
|
||||||
|
this.status = status;
|
||||||
|
this.isPublic = isPublic;
|
||||||
|
this.isOperational = true; // This is required since bluebird 4 doesn't append it anymore.
|
||||||
|
this.stack = stack;
|
||||||
|
// Error.captureStackTrace(this, this.constructor.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing an API error.
|
||||||
|
* @extends ExtendableError
|
||||||
|
*/
|
||||||
|
class APIError extends ExtendableError {
|
||||||
|
/**
|
||||||
|
* Creates an API error.
|
||||||
|
* @param {string} message - Error message.
|
||||||
|
* @param {number} status - HTTP status code of error.
|
||||||
|
* @param {boolean} isPublic - Whether the message should be visible to user or not.
|
||||||
|
*/
|
||||||
|
constructor({
|
||||||
|
message,
|
||||||
|
errors,
|
||||||
|
stack,
|
||||||
|
status = httpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
isPublic = false,
|
||||||
|
}) {
|
||||||
|
super({
|
||||||
|
message, errors, status, isPublic, stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = APIError;
|
584
src/api/utils/passwordResetEmailTemplate.html
Normal file
584
src/api/utils/passwordResetEmailTemplate.html
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
<!DOCTYPE html
|
||||||
|
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="x-apple-disable-message-reformatting" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title></title>
|
||||||
|
<style type="text/css" rel="stylesheet" media="all">
|
||||||
|
/* Base ------------------------------ */
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #3869D4;
|
||||||
|
}
|
||||||
|
|
||||||
|
a img {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preheader {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
mso-hide: all;
|
||||||
|
font-size: 1px;
|
||||||
|
line-height: 1px;
|
||||||
|
max-height: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type ------------------------------ */
|
||||||
|
|
||||||
|
body,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
margin: .4em 0 1.1875em;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.625;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sub {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities ------------------------------ */
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons ------------------------------ */
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background-color: #3869D4;
|
||||||
|
border-top: 10px solid #3869D4;
|
||||||
|
border-right: 18px solid #3869D4;
|
||||||
|
border-bottom: 10px solid #3869D4;
|
||||||
|
border-left: 18px solid #3869D4;
|
||||||
|
display: inline-block;
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--green {
|
||||||
|
background-color: #22BC66;
|
||||||
|
border-top: 10px solid #22BC66;
|
||||||
|
border-right: 18px solid #22BC66;
|
||||||
|
border-bottom: 10px solid #22BC66;
|
||||||
|
border-left: 18px solid #22BC66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--red {
|
||||||
|
background-color: #FF6136;
|
||||||
|
border-top: 10px solid #FF6136;
|
||||||
|
border-right: 18px solid #FF6136;
|
||||||
|
border-bottom: 10px solid #FF6136;
|
||||||
|
border-left: 18px solid #FF6136;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 500px) {
|
||||||
|
.button {
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attribute list ------------------------------ */
|
||||||
|
|
||||||
|
.attributes {
|
||||||
|
margin: 0 0 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_content {
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Related Items ------------------------------ */
|
||||||
|
|
||||||
|
.related {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #CBCCCF;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item-title {
|
||||||
|
display: block;
|
||||||
|
margin: .5em 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_item-thumb {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related_heading {
|
||||||
|
border-top: 1px solid #CBCCCF;
|
||||||
|
text-align: center;
|
||||||
|
padding: 25px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discount Code ------------------------------ */
|
||||||
|
|
||||||
|
.discount {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
background-color: #F4F4F7;
|
||||||
|
border: 2px dashed #CBCCCF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_body {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Social Icons ------------------------------ */
|
||||||
|
|
||||||
|
.social {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social td {
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social_icon {
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 8px 10px 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data table ------------------------------ */
|
||||||
|
|
||||||
|
.purchase {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 35px 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 25px 0 0 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_item {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #51545E;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_heading p {
|
||||||
|
margin: 0;
|
||||||
|
color: #85878E;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_footer {
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_total {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_total--label {
|
||||||
|
padding: 0 15px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Masthead ----------------------- */
|
||||||
|
|
||||||
|
.email-masthead {
|
||||||
|
padding: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_logo {
|
||||||
|
width: 94px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #A8AAAF;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 1px 0 white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body ------------------------------ */
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body_inner {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer p {
|
||||||
|
color: #A8AAAF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-action {
|
||||||
|
width: 100%;
|
||||||
|
margin: 30px auto;
|
||||||
|
padding: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-sub {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 25px;
|
||||||
|
border-top: 1px solid #EAEAEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-cell {
|
||||||
|
padding: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*Media Queries ------------------------------ */
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
|
||||||
|
.email-body_inner,
|
||||||
|
.email-footer {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #333333 !important;
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
color: #FFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_content,
|
||||||
|
.discount {
|
||||||
|
background-color: #222 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_name {
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
.f-fallback {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<style type="text/css" rel="stylesheet" media="all">
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body
|
||||||
|
style="width: 100% !important; height: 100%; -webkit-text-size-adjust: none; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; background-color: #FFF; color: #333; margin: 0;"
|
||||||
|
bgcolor="#FFF">
|
||||||
|
<span class="preheader"
|
||||||
|
style="display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;">Use
|
||||||
|
this link to reset your password. The link is only valid for 24 hours.</span>
|
||||||
|
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;">
|
||||||
|
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
|
||||||
|
<tr>
|
||||||
|
<td class="email-masthead"
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;"
|
||||||
|
align="center">
|
||||||
|
<a href="https://example.com" class="f-fallback email-masthead_name"
|
||||||
|
style="color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;">
|
||||||
|
[Product Name]
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Email Body -->
|
||||||
|
<tr>
|
||||||
|
<td class="email-body" width="570" cellpadding="0" cellspacing="0"
|
||||||
|
style="word-break: break-word; margin: 0; padding: 0; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0;">
|
||||||
|
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0 auto; padding: 0;">
|
||||||
|
<!-- Body content -->
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell"
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;">
|
||||||
|
<div class="f-fallback">
|
||||||
|
<h1 style="margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;"
|
||||||
|
align="left">Hi {{name}},</h1>
|
||||||
|
<p
|
||||||
|
style="font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
|
||||||
|
You recently requested to reset your password for your [Product Name]
|
||||||
|
account. Use the button below to reset it. <strong>This password reset
|
||||||
|
is only valid for the next 2 hours.</strong></p>
|
||||||
|
<!-- Action -->
|
||||||
|
<table class="body-action" align="center" width="100%" cellpadding="0"
|
||||||
|
cellspacing="0" role="presentation"
|
||||||
|
style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;">
|
||||||
|
<!-- Border based button
|
||||||
|
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0"
|
||||||
|
role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;">
|
||||||
|
<a href="{{action_url}}"
|
||||||
|
class="f-fallback button button--green"
|
||||||
|
target="_blank"
|
||||||
|
style="color: #FFF; border-color: #22bc66; border-style: solid; border-width: 10px 18px; background-color: #22BC66; display: inline-block; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box;">Reset
|
||||||
|
your password</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p
|
||||||
|
style="font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
|
||||||
|
For security, this request was received from a {{operating_system}}
|
||||||
|
device using {{browser_name}}. If you did not request a password reset,
|
||||||
|
please ignore this email or <a href="{{support_url}}"
|
||||||
|
style="color: #3869D4;">contact support</a> if you have questions.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style="font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
|
||||||
|
Thanks,
|
||||||
|
<br />The [Product Name] Team</p>
|
||||||
|
<!-- Sub copy -->
|
||||||
|
<table class="body-sub" role="presentation"
|
||||||
|
style="margin-top: 25px; padding-top: 25px; border-top-width: 1px; border-top-color: #EAEAEC; border-top-style: solid;">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;">
|
||||||
|
<p class="f-fallback sub"
|
||||||
|
style="font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
|
||||||
|
If you’re having trouble with the button above, copy and
|
||||||
|
paste the URL below into your web browser.</p>
|
||||||
|
<p class="f-fallback sub"
|
||||||
|
style="font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
|
||||||
|
{{action_url}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;">
|
||||||
|
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 0 auto; padding: 0;">
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell" align="center"
|
||||||
|
style="word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;">
|
||||||
|
<p class="f-fallback sub align-center"
|
||||||
|
style="font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;"
|
||||||
|
align="center">© 2019 [Product Name]. All rights reserved.</p>
|
||||||
|
<p class="f-fallback sub align-center"
|
||||||
|
style="font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;"
|
||||||
|
align="center">
|
||||||
|
[Company Name, LLC]
|
||||||
|
<br />1234 Street Rd.
|
||||||
|
<br />Suite 1234
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
69
src/api/validations/auth.validation.js
Normal file
69
src/api/validations/auth.validation.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// POST /v1/auth/register
|
||||||
|
register: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required(),
|
||||||
|
password: Joi.string()
|
||||||
|
.required()
|
||||||
|
.min(6)
|
||||||
|
.max(128),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /v1/auth/login
|
||||||
|
login: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required(),
|
||||||
|
password: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(128),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /v1/auth/facebook
|
||||||
|
// POST /v1/auth/google
|
||||||
|
oAuth: {
|
||||||
|
body: {
|
||||||
|
access_token: Joi.string().required(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /v1/auth/refresh
|
||||||
|
refresh: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required(),
|
||||||
|
refreshToken: Joi.string().required(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /v1/auth/refresh
|
||||||
|
sendPasswordReset: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /v1/auth/password-reset
|
||||||
|
passwordReset: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required(),
|
||||||
|
password: Joi.string()
|
||||||
|
.required()
|
||||||
|
.min(6)
|
||||||
|
.max(128),
|
||||||
|
resetToken: Joi.string().required(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
52
src/api/validations/user.validation.js
Normal file
52
src/api/validations/user.validation.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
const User = require('../models/user.model');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
|
||||||
|
// GET /v1/users
|
||||||
|
listUsers: {
|
||||||
|
query: {
|
||||||
|
page: Joi.number().min(1),
|
||||||
|
perPage: Joi.number().min(1).max(100),
|
||||||
|
name: Joi.string(),
|
||||||
|
email: Joi.string(),
|
||||||
|
role: Joi.string().valid(User.roles),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /v1/users
|
||||||
|
createUser: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string().email().required(),
|
||||||
|
password: Joi.string().min(6).max(128).required(),
|
||||||
|
name: Joi.string().max(128),
|
||||||
|
role: Joi.string().valid(User.roles),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// PUT /v1/users/:userId
|
||||||
|
replaceUser: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string().email().required(),
|
||||||
|
password: Joi.string().min(6).max(128).required(),
|
||||||
|
name: Joi.string().max(128),
|
||||||
|
role: Joi.string().valid(User.roles),
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
userId: Joi.string().regex(/^[a-fA-F0-9]{24}$/).required(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// PATCH /v1/users/:userId
|
||||||
|
updateUser: {
|
||||||
|
body: {
|
||||||
|
email: Joi.string().email(),
|
||||||
|
password: Joi.string().min(6).max(128),
|
||||||
|
name: Joi.string().max(128),
|
||||||
|
role: Joi.string().valid(User.roles),
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
userId: Joi.string().regex(/^[a-fA-F0-9]{24}$/).required(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
58
src/config/express.js
Normal file
58
src/config/express.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
const compress = require('compression');
|
||||||
|
const methodOverride = require('method-override');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const passport = require('passport');
|
||||||
|
const routes = require('../api/routes/v1');
|
||||||
|
const { logs } = require('./vars');
|
||||||
|
const strategies = require('./passport');
|
||||||
|
const error = require('../api/middlewares/error');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express instance
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// request logging. dev: console | production: file
|
||||||
|
app.use(morgan(logs));
|
||||||
|
|
||||||
|
// parse body params and attache them to req.body
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// gzip compression
|
||||||
|
app.use(compress());
|
||||||
|
|
||||||
|
// lets you use HTTP verbs such as PUT or DELETE
|
||||||
|
// in places where the client doesn't support it
|
||||||
|
app.use(methodOverride());
|
||||||
|
|
||||||
|
// secure apps by setting various HTTP headers
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// enable CORS - Cross Origin Resource Sharing
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// enable authentication
|
||||||
|
app.use(passport.initialize());
|
||||||
|
passport.use('jwt', strategies.jwt);
|
||||||
|
passport.use('facebook', strategies.facebook);
|
||||||
|
passport.use('google', strategies.google);
|
||||||
|
|
||||||
|
// mount api v1 routes
|
||||||
|
app.use('/v1', routes);
|
||||||
|
|
||||||
|
// if error is not an instanceOf APIError, convert it.
|
||||||
|
app.use(error.converter);
|
||||||
|
|
||||||
|
// catch 404 and forward to error handler
|
||||||
|
app.use(error.notFound);
|
||||||
|
|
||||||
|
// error handler, send stacktrace only during development
|
||||||
|
app.use(error.handler);
|
||||||
|
|
||||||
|
module.exports = app;
|
32
src/config/logger.js
Normal file
32
src/config/logger.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.json(),
|
||||||
|
transports: [
|
||||||
|
//
|
||||||
|
// - Write to all logs with level `info` and below to `combined.log`
|
||||||
|
// - Write all logs error (and below) to `error.log`.
|
||||||
|
//
|
||||||
|
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||||
|
new winston.transports.File({ filename: 'combined.log' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// If we're not in production then log to the `console` with the format:
|
||||||
|
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
|
||||||
|
//
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.simple(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.stream = {
|
||||||
|
write: (message) => {
|
||||||
|
logger.info(message.trim());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = logger;
|
36
src/config/mongoose.js
Normal file
36
src/config/mongoose.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const logger = require('./../config/logger');
|
||||||
|
const { mongo, env } = require('./vars');
|
||||||
|
|
||||||
|
// set mongoose Promise to Bluebird
|
||||||
|
mongoose.Promise = Promise;
|
||||||
|
|
||||||
|
// Exit application on error
|
||||||
|
mongoose.connection.on('error', (err) => {
|
||||||
|
logger.error(`MongoDB connection error: ${err}`);
|
||||||
|
process.exit(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// print mongoose logs in dev env
|
||||||
|
if (env === 'development') {
|
||||||
|
mongoose.set('debug', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to mongo db
|
||||||
|
*
|
||||||
|
* @returns {object} Mongoose connection
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exports.connect = () => {
|
||||||
|
mongoose
|
||||||
|
.connect(mongo.uri, {
|
||||||
|
useCreateIndex: true,
|
||||||
|
keepAlive: 1,
|
||||||
|
useNewUrlParser: true,
|
||||||
|
useUnifiedTopology: true,
|
||||||
|
useFindAndModify: false,
|
||||||
|
})
|
||||||
|
.then(() => console.log('mongoDB connected...'));
|
||||||
|
return mongoose.connection;
|
||||||
|
};
|
35
src/config/passport.js
Normal file
35
src/config/passport.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const JwtStrategy = require('passport-jwt').Strategy;
|
||||||
|
const BearerStrategy = require('passport-http-bearer');
|
||||||
|
const { ExtractJwt } = require('passport-jwt');
|
||||||
|
const { jwtSecret } = require('./vars');
|
||||||
|
const authProviders = require('../api/services/authProviders');
|
||||||
|
const User = require('../api/models/user.model');
|
||||||
|
|
||||||
|
const jwtOptions = {
|
||||||
|
secretOrKey: jwtSecret,
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('Bearer'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const jwt = async (payload, done) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(payload.sub);
|
||||||
|
if (user) return done(null, user);
|
||||||
|
return done(null, false);
|
||||||
|
} catch (error) {
|
||||||
|
return done(error, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const oAuth = service => async (token, done) => {
|
||||||
|
try {
|
||||||
|
const userData = await authProviders[service](token);
|
||||||
|
const user = await User.oAuthLogin(userData);
|
||||||
|
return done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
return done(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.jwt = new JwtStrategy(jwtOptions, jwt);
|
||||||
|
exports.facebook = new BearerStrategy(oAuth('facebook'));
|
||||||
|
exports.google = new BearerStrategy(oAuth('google'));
|
24
src/config/vars.js
Normal file
24
src/config/vars.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// import .env variables
|
||||||
|
require('dotenv-safe').load({
|
||||||
|
path: path.join(__dirname, '../../.env'),
|
||||||
|
sample: path.join(__dirname, '../../.env.example'),
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: process.env.NODE_ENV,
|
||||||
|
port: process.env.PORT,
|
||||||
|
jwtSecret: process.env.JWT_SECRET,
|
||||||
|
jwtExpirationInterval: process.env.JWT_EXPIRATION_MINUTES,
|
||||||
|
mongo: {
|
||||||
|
uri: process.env.NODE_ENV === 'test' ? process.env.MONGO_URI_TESTS : process.env.MONGO_URI,
|
||||||
|
},
|
||||||
|
logs: process.env.NODE_ENV === 'production' ? 'combined' : 'dev',
|
||||||
|
emailConfig: {
|
||||||
|
host: process.env.EMAIL_HOST,
|
||||||
|
port: process.env.EMAIL_PORT,
|
||||||
|
username: process.env.EMAIL_USERNAME,
|
||||||
|
password: process.env.EMAIL_PASSWORD,
|
||||||
|
},
|
||||||
|
};
|
17
src/index.js
Normal file
17
src/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// make bluebird default Promise
|
||||||
|
Promise = require('bluebird'); // eslint-disable-line no-global-assign
|
||||||
|
const { port, env } = require('./config/vars');
|
||||||
|
const logger = require('./config/logger');
|
||||||
|
const app = require('./config/express');
|
||||||
|
const mongoose = require('./config/mongoose');
|
||||||
|
|
||||||
|
// open mongoose connection
|
||||||
|
|
||||||
|
// listen to requests
|
||||||
|
app.listen(port, () => logger.info(`server started on port ${port} (${env})`));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports express
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
module.exports = app;
|
Loading…
x
Reference in New Issue
Block a user