@ -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"] |
@ -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 | |||||
``` | |||||
@ -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 |
@ -0,0 +1,6 @@ | |||||
version: "2" | |||||
services: | |||||
boilerplate-api: | |||||
command: yarn dev -- -L | |||||
environment: | |||||
- NODE_ENV=development |
@ -0,0 +1,6 @@ | |||||
version: "2" | |||||
services: | |||||
boilerplate-api: | |||||
command: yarn start | |||||
environment: | |||||
- NODE_ENV=production |
@ -0,0 +1,6 @@ | |||||
version: "2" | |||||
services: | |||||
boilerplate-api: | |||||
command: yarn test | |||||
environment: | |||||
- NODE_ENV=test |
@ -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" |
@ -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" | |||||
} | |||||
} |
@ -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); | |||||
} | |||||
}; |
@ -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)); | |||||
}; |
@ -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 }); |
@ -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); | |||||
}; |
@ -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; |
@ -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; |
@ -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); |
@ -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; |
@ -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; |
@ -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; |
@ -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, | |||||
}; | |||||
}; |
@ -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, | |||||
}); | |||||
.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, | |||||
}); | |||||
.send({ | |||||
template: 'passwordChange', | |||||
message: { | |||||
to: user.email, | |||||
}, | |||||
locals: { | |||||
productName: 'Test App', | |||||
name: user.name, | |||||
}, | |||||
}) | |||||
.catch(() => console.log('error sending change password email')); | |||||
}; |
@ -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. |
@ -0,0 +1 @@ | |||||
= `${productName} Password Changed` |
@ -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 |
@ -0,0 +1 @@ | |||||
= `${productName} Password Reset` |
@ -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'); | |||||
}); | |||||
}); | |||||
}); | |||||
}); |
@ -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'); | |||||
}); | |||||
}); | |||||
}); | |||||
}); |
@ -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; |
@ -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> |
@ -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(), | |||||
}, | |||||
}, | |||||
}; |
@ -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(), | |||||
}, | |||||
}, | |||||
}; |
@ -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; |
@ -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; |
@ -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; | |||||
}; |
@ -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')); |
@ -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, | |||||
}, | |||||
}; |
@ -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; |