@ -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; |