├── api
├── .nvmrc
├── .eslintrc
├── config
│ ├── billing.js
│ ├── email.js
│ ├── server.js
│ ├── index.js
│ ├── database.js
│ └── auth.js
├── utils
│ ├── error-utils.js
│ ├── email-utils.js
│ ├── user-utils.js
│ └── validation-utils.js
├── Dockerfile
├── routes
│ ├── index.js
│ ├── billing.js
│ ├── user.js
│ └── auth.js
├── index.js
├── constants.js
├── package.json
├── controllers
│ ├── user.js
│ ├── billing.js
│ └── auth.js
├── models
│ └── user.js
└── test
│ └── user.test.js
├── app
├── .nvmrc
├── src
│ ├── constants
│ │ ├── ui-constants.js
│ │ └── key-constants.js
│ ├── components
│ │ ├── authentication
│ │ │ ├── authentication.scss
│ │ │ ├── forgot-password.js
│ │ │ ├── reset-password.js
│ │ │ ├── login.js
│ │ │ └── register.js
│ │ ├── general
│ │ │ ├── card.scss
│ │ │ ├── card.js
│ │ │ ├── modal.scss
│ │ │ └── modal.js
│ │ ├── form-fields
│ │ │ ├── checkbox.js
│ │ │ ├── text-area.js
│ │ │ ├── text-input.js
│ │ │ ├── select.js
│ │ │ └── generic-form.js
│ │ ├── notification
│ │ │ └── alert.js
│ │ ├── hoc
│ │ │ └── require-auth.js
│ │ ├── billing
│ │ │ └── credit-card-fields.js
│ │ └── header
│ │ │ └── header.js
│ ├── routes
│ │ ├── authenticated
│ │ │ └── index.js
│ │ └── index.js
│ ├── assets
│ │ └── stylesheets
│ │ │ ├── partials
│ │ │ ├── _variables.scss
│ │ │ ├── _mixins.scss
│ │ │ ├── _state.scss
│ │ │ ├── _grid.scss
│ │ │ ├── _buttons.scss
│ │ │ ├── _header.scss
│ │ │ └── _forms.scss
│ │ │ └── base.scss
│ ├── util
│ │ ├── redux-constants.js
│ │ ├── proptype-utils.js
│ │ ├── cookie-utils.js
│ │ ├── environment-utils.js
│ │ ├── i18n.js
│ │ ├── store-utils.js
│ │ └── http-utils.js
│ ├── index.html
│ ├── redux
│ │ ├── index.js
│ │ └── modules
│ │ │ ├── billing.js
│ │ │ ├── user.js
│ │ │ └── authentication.js
│ ├── index.js
│ └── i18n.js
├── .babelrc
├── .eslintrc
├── webpack.config.js
└── package.json
├── .vscode
└── launch.json
├── .gitignore
└── README.md
/api/.nvmrc:
--------------------------------------------------------------------------------
1 | 8.1.4
2 |
--------------------------------------------------------------------------------
/app/.nvmrc:
--------------------------------------------------------------------------------
1 | 8.1.4
2 |
--------------------------------------------------------------------------------
/app/src/constants/ui-constants.js:
--------------------------------------------------------------------------------
1 | export const mobileBreakpoint = 766;
2 |
--------------------------------------------------------------------------------
/app/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "react"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/components/authentication/authentication.scss:
--------------------------------------------------------------------------------
1 | .auth-box {
2 | width: 425px;
3 | margin: 0 auto;
4 | }
--------------------------------------------------------------------------------
/app/src/constants/key-constants.js:
--------------------------------------------------------------------------------
1 | // Demo test Stripe publishable key (replace with your own)
2 | export const STRIPE_PUBLIC_KEY = 'pk_test_6pRNASCoBOKtIshFeQd4XMUh';
3 |
--------------------------------------------------------------------------------
/api/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "browser": true,
6 | "node": true,
7 | "es6": true,
8 | "jest": true
9 | },
10 | "rules": {
11 | "no-underscore-dangle": ["error", { "allow": ["_id"] }],
12 | "jsx-a11y/href-no-hash": "off",
13 | "no-console": "off"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/api/config/billing.js:
--------------------------------------------------------------------------------
1 | module.exports = () => {
2 | // Default to dev presets
3 | const billingConfig = {
4 | stripeApiKey: 'your-key',
5 | };
6 |
7 | switch (process.env.NODE_ENV) {
8 | case 'production':
9 | break;
10 | case 'stage':
11 | break;
12 | case 'dev':
13 | default:
14 | break;
15 | }
16 |
17 | return billingConfig;
18 | };
19 |
--------------------------------------------------------------------------------
/api/utils/error-utils.js:
--------------------------------------------------------------------------------
1 | const errorHandler = async (ctx, next) => {
2 | try {
3 | await next();
4 | } catch (err) {
5 | ctx.status = err.status || 500;
6 | ctx.body = err.message;
7 | ctx.app.emit('error', err, ctx);
8 | }
9 | };
10 |
11 | const errorLogger = (err) => {
12 | console.error(err);
13 | };
14 |
15 | module.exports = {
16 | errorHandler,
17 | errorLogger,
18 | };
19 |
--------------------------------------------------------------------------------
/api/config/email.js:
--------------------------------------------------------------------------------
1 | module.exports = () => {
2 | // Default to dev presets
3 | const emailConfig = {
4 | apiKey: 'key-xxxx',
5 | domain: 'mg.yourdomain.com',
6 | };
7 |
8 | switch (process.env.NODE_ENV) {
9 | case 'production':
10 | break;
11 | case 'stage':
12 | break;
13 | case 'dev':
14 | default:
15 | break;
16 | }
17 |
18 | return emailConfig;
19 | };
20 |
--------------------------------------------------------------------------------
/app/src/routes/authenticated/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Switch } from 'react-router-dom';
3 | import { Translate } from 'react-i18nify';
4 |
5 | const AuthenticatedRoutes = () => (
6 |
7 |
} />
8 |
9 | );
10 |
11 | export default AuthenticatedRoutes;
12 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/partials/_variables.scss:
--------------------------------------------------------------------------------
1 | // Colors
2 | $primary-color: #0474D8;
3 | $secondary-color: #363636;
4 | $gray-border: #dbdbdb;
5 | $error-color: #f96161;
6 |
7 | // Forms
8 | $input-color: #000;
9 | $input-bg: #FFF;
10 | $input-border: #CCC;
11 | $focus-color: #79b8f5;
12 | $border-radius: 3px;
13 | $unit: 8px;
14 |
15 | // Header
16 | $header-height: 60px;
17 |
18 | // Spacing
19 | $wrapper-horiz-pad: 10px;
--------------------------------------------------------------------------------
/app/src/util/redux-constants.js:
--------------------------------------------------------------------------------
1 | // Namespace for application
2 | export const APP_NAMESPACE = 'mkrn-starter';
3 |
4 | // Request status indicators
5 | export const PENDING = 'PENDING';
6 | export const SUCCESS = 'SUCCESS';
7 | export const ERROR = 'ERROR';
8 |
9 | // Request types (lowercase for easy axios access)
10 | export const DELETE = 'delete';
11 | export const GET = 'get';
12 | export const POST = 'post';
13 | export const PUT = 'put';
14 |
--------------------------------------------------------------------------------
/api/config/server.js:
--------------------------------------------------------------------------------
1 | module.exports = () => {
2 | // Default to dev presets
3 | const serverConfig = {
4 | port: 3000,
5 | };
6 |
7 | switch (process.env.NODE_ENV) {
8 | case 'production':
9 | break;
10 | case 'stage':
11 | break;
12 | case 'test':
13 | Object.assign(serverConfig, { port: 3001 });
14 | break;
15 | case 'dev':
16 | default:
17 | break;
18 | }
19 |
20 | return serverConfig;
21 | };
22 |
--------------------------------------------------------------------------------
/app/src/components/general/card.scss:
--------------------------------------------------------------------------------
1 | .card {
2 | background-color: white;
3 | -webkit-box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
4 | box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
5 | max-width: 100%;
6 | position: relative;
7 |
8 | .card- {
9 | &content {
10 | padding: 1.5rem;
11 | }
12 |
13 | &media {
14 | display: block;
15 | position: relative;
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/api/Dockerfile:
--------------------------------------------------------------------------------
1 | # configure container
2 | FROM node:8
3 |
4 | # install and update system dependencies
5 | RUN apt-get update
6 | RUN apt-get install -y zip curl vim
7 | RUN npm install -g pm2
8 |
9 | # create api directory
10 | RUN mkdir -p /www/mkrn-starter-apipm2
11 | WORKDIR /www/mkrn-starter-api
12 | COPY . /www/mkrn-starter-api
13 |
14 | # install application
15 | RUN yarn install
16 |
17 | # add source
18 | EXPOSE 3000
19 | CMD ["pm2", "start", "--no-daemon", "index.js"]
20 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible Node.js debug attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Launch Program",
11 | "program": "${file}"
12 | }
13 | ]
14 | }
--------------------------------------------------------------------------------
/api/routes/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const combineRouters = require('koa-combine-routers');
4 |
5 | const baseName = path.basename(module.filename);
6 |
7 | const routes = fs
8 | .readdirSync(path.join(__dirname))
9 | .filter(file => (file.indexOf('.') !== 0) && (file !== baseName) && (file.slice(-3) === '.js'))
10 | .map(file => require(path.join(__dirname, file)));
11 |
12 | const rootRouter = combineRouters(routes);
13 |
14 | module.exports = rootRouter;
15 |
--------------------------------------------------------------------------------
/api/config/index.js:
--------------------------------------------------------------------------------
1 | const serverConfig = require('./server')();
2 | const databaseConfig = require('./database')();
3 | const emailConfig = require('./email')();
4 | const authConfig = require('./auth');
5 | const billingConfig = require('./billing');
6 |
7 | module.exports = {
8 | server: serverConfig,
9 | database: databaseConfig,
10 | passport: authConfig.passport(),
11 | email: emailConfig,
12 | auth: {
13 | secret: authConfig.opts.secret,
14 | jwtExpiration: authConfig.opts.expiration,
15 | },
16 | billing: billingConfig,
17 | };
18 |
--------------------------------------------------------------------------------
/app/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "browser": true,
6 | "node": true,
7 | "es6": true,
8 | "jest": true
9 | },
10 | "rules": {
11 | "jsx-a11y/href-no-hash": "off",
12 | "no-underscore-dangle": ["error", { "allow": ["_id"] }],
13 | "no-console": "off",
14 | "no-param-reassign": "off",
15 | "no-script-url": "off",
16 | "react/jsx-filename-extension": "off",
17 | "react/require-default-props": "off"
18 | },
19 | "globals": {
20 | "Stripe": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/util/proptype-utils.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 |
3 | export const errorPropTypes = PropTypes.arrayOf(PropTypes.shape({
4 | error: PropTypes.string,
5 | }));
6 |
7 | export const fieldPropTypes = {
8 | input: PropTypes.shape({
9 | name: PropTypes.string,
10 | value: PropTypes.string,
11 | }),
12 | meta: PropTypes.shape({
13 | error: PropTypes.string,
14 | }),
15 | id: PropTypes.string,
16 | placeholder: PropTypes.string,
17 | type: PropTypes.string,
18 | extraClasses: PropTypes.string,
19 | label: PropTypes.string,
20 | };
21 |
--------------------------------------------------------------------------------
/api/routes/billing.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 | const billingControllers = require('../controllers/billing');
3 | const authControllers = require('../controllers/auth');
4 |
5 | const {
6 | stripeWebhook,
7 | createSubscription,
8 | createCustomer,
9 | } = billingControllers;
10 |
11 | const {
12 | jwtAuth,
13 | } = authControllers;
14 |
15 | const router = new Router({ prefix: '/billing' });
16 |
17 | router.post('/webhook', stripeWebhook);
18 | router.post('/subscription', jwtAuth, createSubscription, createCustomer);
19 |
20 | module.exports = router;
21 |
--------------------------------------------------------------------------------
/app/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | MKRN Starter
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/api/routes/user.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 | const userControllers = require('../controllers/user');
3 | const authControllers = require('../controllers/auth');
4 |
5 | const {
6 | jwtAuth,
7 | } = authControllers;
8 |
9 | const {
10 | getUser,
11 | getUsers,
12 | deleteUser,
13 | editUser,
14 | } = userControllers;
15 |
16 | const router = new Router({ prefix: '/user' });
17 |
18 | router.get('/', jwtAuth, getUsers);
19 | router.get('/:id', jwtAuth, getUser);
20 | router.delete('/:id', jwtAuth, deleteUser);
21 | router.put('/:id', jwtAuth, editUser);
22 |
23 | module.exports = router;
24 |
--------------------------------------------------------------------------------
/app/src/components/form-fields/checkbox.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { fieldPropTypes } from '../../util/proptype-utils';
3 |
4 | const Checkbox = ({ input, meta, id, label = '', extraClasses = '' }) => (
5 |
6 |
7 |
8 | {meta.touched && meta.error &&
{meta.error}
}
9 |
10 | );
11 |
12 | Checkbox.propTypes = fieldPropTypes;
13 |
14 | export default Checkbox;
15 |
--------------------------------------------------------------------------------
/api/routes/auth.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 | const authControllers = require('../controllers/auth');
3 |
4 | const {
5 | jwtAuth,
6 | login,
7 | register,
8 | forgotPassword,
9 | resetPassword,
10 | getAuthenticatedUser,
11 | } = authControllers;
12 |
13 | const router = new Router({ prefix: '/auth' });
14 |
15 | router.post('/register', register);
16 | router.post('/login', login);
17 | router.post('/forgot-password', forgotPassword);
18 | router.post('/reset-password/:resetToken', resetPassword);
19 | router.get('/profile', jwtAuth, getAuthenticatedUser);
20 |
21 | module.exports = router;
22 |
--------------------------------------------------------------------------------
/api/config/database.js:
--------------------------------------------------------------------------------
1 | module.exports = () => {
2 | // Default to dev presets
3 | const dbConfig = {
4 | url: 'mongodb://localhost:27017/dev',
5 | opts: {
6 | useNewUrlParser: true,
7 | useUnifiedTopology: true,
8 | keepAlive: 300000,
9 | },
10 | };
11 |
12 | switch (process.env.NODE_ENV) {
13 | case 'production':
14 | break;
15 | case 'stage':
16 | break;
17 | case 'test':
18 | Object.assign(dbConfig, { url: 'mongodb://localhost:27017/test' });
19 | break;
20 | case 'dev':
21 | default:
22 | break;
23 | }
24 |
25 | return dbConfig;
26 | };
27 |
--------------------------------------------------------------------------------
/api/utils/email-utils.js:
--------------------------------------------------------------------------------
1 | const config = require('../config');
2 | const mailgun = require('mailgun-js')(config.email);
3 |
4 | exports.sendEmail = (recipient, message, attachment) =>
5 | new Promise((resolve, reject) => {
6 | const data = {
7 | from: 'Your Site ',
8 | to: recipient,
9 | subject: message.subject,
10 | text: message.text,
11 | inline: attachment,
12 | html: message.html,
13 | };
14 |
15 | mailgun.messages().send(data, (error) => {
16 | if (error) {
17 | return reject(error);
18 | }
19 | return resolve();
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/partials/_mixins.scss:
--------------------------------------------------------------------------------
1 | @mixin font-face($font-family, $size, $weight: null) {
2 | font-family: $font-family;
3 | font-size: $size;
4 | font-weight: $weight;
5 | }
6 |
7 | @mixin breakpoint($point) {
8 | @if $point == small {
9 | @media (max-width: 30em) { @content; }
10 | }
11 | @else if $point == medium {
12 | @media (min-width: 30em) and (max-width: 50em) { @content; }
13 | }
14 | @else if $point == large {
15 | @media (min-width: 50em) and (max-width: 68.75em) { @content; }
16 | }
17 | }
18 |
19 | @mixin clearfix() {
20 | &::after {
21 | content: "";
22 | display: table;
23 | clear: both;
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/redux/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, combineReducers } from 'redux';
2 | import { reducer as formReducer } from 'redux-form';
3 | import reduxThunk from 'redux-thunk';
4 | import userReducer from './modules/user';
5 | import authenticationReducer from './modules/authentication';
6 |
7 | const createStoreWithMiddleware = applyMiddleware(reduxThunk)(createStore);
8 |
9 | const rootReducer = combineReducers({
10 | authentication: authenticationReducer,
11 | user: userReducer,
12 | form: formReducer
13 | });
14 |
15 | const configureStore = initialState => createStoreWithMiddleware(rootReducer, initialState);
16 | export default configureStore;
17 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | const Koa = require('koa');
2 | const mongoose = require('mongoose');
3 | const logger = require('koa-logger');
4 | const cors = require('kcors');
5 | const bodyParser = require('koa-bodyparser');
6 | const routes = require('./routes');
7 | const config = require('./config');
8 |
9 | // Make mongoose use native ES6 promises
10 | mongoose.Promise = global.Promise;
11 |
12 | // Connect to MongoDB
13 | mongoose.connect(config.database.url, config.database.opts);
14 |
15 | const app = new Koa()
16 | .use(cors())
17 | .use(logger())
18 | .use(bodyParser())
19 | .use(routes);
20 |
21 | const server = app.listen(config.server.port);
22 |
23 | module.exports = server;
24 |
--------------------------------------------------------------------------------
/app/src/components/form-fields/text-area.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { fieldPropTypes } from '../../util/proptype-utils';
3 |
4 | const Textarea = ({ input, meta, id, placeholder, type, label = '', extraClasses = '' }) => (
5 |
15 | );
16 |
17 | Textarea.propTypes = fieldPropTypes;
18 |
19 | export default Textarea;
20 |
--------------------------------------------------------------------------------
/app/src/components/form-fields/text-input.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { fieldPropTypes } from '../../util/proptype-utils';
3 |
4 | const TextInput = ({ input, meta, id, placeholder, type, label = '', extraClasses = '' }) => (
5 |
16 | );
17 |
18 | TextInput.propTypes = fieldPropTypes;
19 |
20 | export default TextInput;
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | api/node_modules
31 | app/node_modules
32 | node_modules
33 | jspm_packages
34 |
35 | # Optional npm cache directory
36 | .npm
37 |
38 | # Optional REPL history
39 | .node_repl_history
40 |
41 | .vscode
--------------------------------------------------------------------------------
/app/src/components/form-fields/select.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { fieldPropTypes } from '../../util/proptype-utils';
4 |
5 | const Select = ({ input, children, meta, id, placeholder, label = '', extraClasses = '' }) => (
6 |
18 | );
19 |
20 | Select.propTypes = {
21 | ...fieldPropTypes,
22 | children: PropTypes.node,
23 | };
24 |
25 | export default Select;
26 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/partials/_state.scss:
--------------------------------------------------------------------------------
1 | // Animations
2 | @keyframes spinner {
3 | to {transform: rotate(360deg);}
4 | }
5 |
6 | .is- {
7 | &hidden {
8 | display: none;
9 | }
10 |
11 | &loading {
12 | display: block;
13 | position: relative;
14 |
15 | > *:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6) {
16 | visibility: hidden;
17 | }
18 |
19 | &::before {
20 | content: '';
21 | box-sizing: border-box;
22 | position: absolute;
23 | top: 50%;
24 | left: 50%;
25 | width: 30px;
26 | height: 30px;
27 | margin-top: -15px;
28 | margin-left: -15px;
29 | border-radius: 50%;
30 | border: 3px solid #ccc;
31 | border-top-color: $primary-color;
32 | animation: spinner .6s linear infinite;
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/routes/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Switch } from 'react-router-dom';
3 | import Login from '../components/authentication/login';
4 | import Register from '../components/authentication/register';
5 | import ForgotPassword from '../components/authentication/forgot-password';
6 | import ResetPassword from '../components/authentication/reset-password';
7 | import RequireAuth from '../components/hoc/require-auth';
8 | import AuthenticatedRoutes from './authenticated/';
9 |
10 | const TopLevelRoutes = () => (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
20 | export default TopLevelRoutes;
21 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/base.scss:
--------------------------------------------------------------------------------
1 | // Mixins
2 | @import "partials/mixins";
3 |
4 | // Variables
5 | @import "partials/variables";
6 |
7 | // Module states
8 | @import "partials/state";
9 |
10 | // ========================
11 | // Template components
12 | // ========================
13 | @import "partials/header";
14 |
15 | // ========================
16 | // Multi-use components
17 | // ========================
18 | @import "partials/buttons";
19 | @import "partials/grid";
20 | @import "partials/forms";
21 |
22 | html, body, #root {
23 | min-height: 100%;
24 | min-width: 100%;
25 | margin: 0;
26 | padding: 0;
27 | @include font-face(Sans-Serif, 14px, normal);
28 | }
29 |
30 | *, *:before, *:after {
31 | box-sizing: border-box;
32 | }
33 |
34 | .clearfix {
35 | @include clearfix();
36 | }
37 |
38 | .left {
39 | float: left;
40 | }
41 |
42 | .right {
43 | float: right;
44 | }
45 |
46 | .inline {
47 | display: inline-block;
48 | }
49 |
50 | .container {
51 | padding: 0 $wrapper-horiz-pad;
52 | }
53 |
54 | main {
55 | padding: 10px;
56 | }
--------------------------------------------------------------------------------
/app/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { BrowserRouter } from 'react-router-dom';
5 | import 'normalize.css';
6 | import configureStore from './redux';
7 | import Header from './components/header/header';
8 | import Routes from './routes/';
9 | import { setLocale, setTranslations } from 'react-i18nify';
10 | import { getClientLanguage } from './util/i18n';
11 | import translations from './i18n';
12 |
13 | // Import stylesheets
14 | import './assets/stylesheets/base.scss';
15 |
16 | const store = configureStore();
17 |
18 | setTranslations(translations);
19 | setLocale(getClientLanguage() || 'en');
20 |
21 | ReactDOM.render((
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | ), document.getElementById('root'));
33 |
34 | // Enable hot relading
35 | module.hot.accept();
36 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/partials/_grid.scss:
--------------------------------------------------------------------------------
1 | // 12 column grid
2 | $numCols: 12;
3 | $colPad: 0.3em;
4 |
5 | // Set up general grid classes
6 | .row {
7 | width: 100%;
8 | margin: 0 auto;
9 | @include clearfix();
10 |
11 | [class*='col-'] {
12 | float: left;
13 | padding: $colPad;
14 | }
15 |
16 | @for $i from 1 through $numCols {
17 | .col-#{$i} {
18 | width: 100% / $i;
19 | }
20 | }
21 | }
22 |
23 | // Responsiveness
24 | @include breakpoint(small) {
25 | @for $i from 1 through $numCols {
26 | .col-#{$i} {
27 | width: 100%;
28 | }
29 | }
30 | }
31 |
32 | @include breakpoint(medium) {
33 | .col-1,
34 | .col-2,
35 | .col-3,
36 | .col-5,
37 | .col-7,
38 | .col-9,
39 | .col-11 {
40 | width: 100%;
41 | }
42 |
43 | .col-4,
44 | .col-6,
45 | .col-8,
46 | .col-10,
47 | .col-12 {
48 | width: 50%;
49 | }
50 | }
51 |
52 | @include breakpoint(large) {
53 | .col-2,
54 | .col-7 {
55 | width: 100%;
56 | }
57 |
58 | .col-4,
59 | .col-8,
60 | .col-10,
61 | .col-12 {
62 | width: 50%;
63 | }
64 | }
--------------------------------------------------------------------------------
/app/src/components/general/card.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Link } from 'react-router-dom';
4 | import './card.scss';
5 |
6 | const Card = ({ title, image = {}, children }) => {
7 | let imageElement = null;
8 |
9 | if (image.linkTo) {
10 | imageElement = (
11 |
12 | );
13 | } else if (image.src) {
14 | imageElement = (
);
15 | }
16 |
17 | return (
18 |
19 | {imageElement}
20 | {title &&
{title}
}
21 |
22 | {children}
23 |
24 |
25 | );
26 | };
27 |
28 | Card.propTypes = {
29 | children: PropTypes.node,
30 | image: PropTypes.shape({
31 | src: PropTypes.string,
32 | title: PropTypes.string,
33 | alt: PropTypes.string,
34 | linkTo: PropTypes.string,
35 | }),
36 | title: PropTypes.string,
37 | };
38 |
39 | export default Card;
40 |
--------------------------------------------------------------------------------
/app/src/util/cookie-utils.js:
--------------------------------------------------------------------------------
1 | import Cookies from 'universal-cookie';
2 | import { getEnvironment } from './environment-utils';
3 |
4 | const cookies = new Cookies();
5 |
6 | /**
7 | * setCookie - Sets a cookie in the user's browser
8 | * @param {String} name Name/key of cookie to save
9 | * @param {String} value Value to save in cookie
10 | * @param {Object} options Options to override defaults
11 | */
12 | export const setCookie = (name, value, options = {}) =>
13 | cookies.set(name, value, Object.assign({
14 | path: '/',
15 | maxAge: 604800,
16 | secure: getEnvironment() === 'production',
17 | }, options));
18 |
19 |
20 | /**
21 | * getCookie - Retrieves a cookie. Not super necessary, but it
22 | * keeps things uniform
23 | * @param {String} name Name of cookie to get
24 | *
25 | * @returns {String}
26 | */
27 | export const getCookie = name => cookies.get(name);
28 |
29 | /**
30 | * deleteCookie - Removes a cookie. Not super necessary, but it
31 | * keeps things uniform
32 | * @param {String} name Name of cookie to get
33 | */
34 | export const deleteCookie = name => cookies.remove(name);
35 |
--------------------------------------------------------------------------------
/api/config/auth.js:
--------------------------------------------------------------------------------
1 | const passport = require('koa-passport');
2 | const LocalStrategy = require('passport-local');
3 | const JwtStrategy = require('passport-jwt');
4 | const User = require('../models/user');
5 |
6 | const SECRET = 'TEST_SECRET';
7 | const localOpts = { usernameField: 'email', session: false };
8 | const jwtOpts = { jwtFromRequest: JwtStrategy.ExtractJwt.fromAuthHeaderWithScheme("jwt"), secretOrKey: SECRET };
9 |
10 | module.exports = {
11 | passport: () => {
12 | const localLogin = new LocalStrategy(localOpts, async (email = '', password = '', done) => {
13 | try {
14 | const user = await User.findOne({ email: email.toLowerCase() });
15 | const isValid = await user.comparePassword(password);
16 |
17 | return done(null, isValid ? user : {});
18 | } catch (err) {
19 | return done(err);
20 | }
21 | });
22 |
23 | const jwtLogin = new JwtStrategy.Strategy(jwtOpts, (payload, done) => done(null, payload));
24 |
25 | passport.use(jwtLogin);
26 | passport.use(localLogin);
27 | return passport;
28 | },
29 | opts: {
30 | secret: SECRET,
31 | expiration: 604800,
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/partials/_buttons.scss:
--------------------------------------------------------------------------------
1 | .button {
2 | border: 1px solid transparent;
3 | border-radius: $border-radius;
4 | text-align: center;
5 | white-space: nowrap;
6 | padding: calc(0.375em - 1px) .75em;
7 | background: none;
8 | height: 2.25em;
9 | line-height: 1.5;
10 |
11 | &:not([disabled]) {
12 | cursor: pointer;
13 | }
14 |
15 | &.is- {
16 | &primary {
17 | background: $primary-color;
18 | color: #FFF;
19 |
20 | &:hover {
21 | background: darken($primary-color, 5%);
22 | }
23 | }
24 |
25 | &danger {
26 | background: $error-color;
27 | color: #FFF;
28 |
29 | &:hover {
30 | background: darken($error-color, 5%);
31 | }
32 | }
33 |
34 | &primary-outline {
35 | border-color: $primary-color;
36 |
37 | &:hover {
38 | border-color: darken($primary-color, 5%);
39 | }
40 | }
41 |
42 | &text {
43 | padding: 0;
44 | text-transform: none;
45 |
46 | &.is-danger {
47 | color: $error-color;
48 |
49 | &:hover {
50 | color: darken($error-color, 5%);
51 | }
52 | }
53 | }
54 | }
55 | }
--------------------------------------------------------------------------------
/app/src/components/general/modal.scss:
--------------------------------------------------------------------------------
1 | .modal {
2 | display: none;
3 | position: fixed;
4 | z-index: 99;
5 | left: 0;
6 | top: 0;
7 | width: 100%;
8 | height: 100%;
9 | overflow: auto;
10 | background-color: rgb(0,0,0);
11 | background-color: rgba(0,0,0,0.4);
12 | text-align: center;
13 |
14 | &.can-scroll {
15 | .modal-content {
16 | overflow-y: scroll;
17 | }
18 | }
19 |
20 | &-container {
21 | position: relative;
22 | width: 100%;
23 | height: 100%;
24 | }
25 |
26 | &-content {
27 | box-sizing: border-box;
28 | background-color: #fefefe;
29 | padding: 20px;
30 | border: 1px solid #888;
31 | min-width: 450px;
32 | position: absolute;
33 | top: 50%;
34 | left: 50%;
35 | transform: translate(-50%, -50%);
36 | max-height: 100vh;
37 | max-width: 100%;
38 |
39 | .close {
40 | float: right;
41 | transition: color 0.2s ease-in-out;
42 |
43 | &:hover {
44 | color: 'red';
45 | cursor: pointer;
46 | }
47 | }
48 |
49 | .button-row {
50 | max-width: 100%;
51 | margin-top: 20px;
52 | }
53 | }
54 |
55 | &.is-open {
56 | display: block;
57 | }
58 | }
--------------------------------------------------------------------------------
/app/src/components/notification/alert.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { errorPropTypes } from '../../util/proptype-utils';
4 |
5 | /**
6 | * Alert - Standard alert card component
7 | * @param {Array} errors Array containing errors
8 | * @param {String} message Message to display
9 | * @param {String} icon Icon to show with alert
10 | * @returns {Function}
11 | */
12 | const Alert = ({ errors = [], message = '', icon }) => {
13 | const alertType = errors && errors.length ? 'errors' : 'message';
14 | const shouldShow = Boolean((errors && errors.length) || message);
15 |
16 | return (
17 |
18 |
{icon}
19 | {(errors && errors.length) &&
20 | errors.map((error, index) => {error.error} )
21 | }
22 | {message && {message}}
23 |
24 |
25 | );
26 | };
27 |
28 | Alert.propTypes = {
29 | errors: errorPropTypes,
30 | icon: PropTypes.string,
31 | message: PropTypes.string,
32 | };
33 |
34 | export default Alert;
35 |
--------------------------------------------------------------------------------
/app/src/components/form-fields/generic-form.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Field } from 'redux-form';
4 | import Alert from '../notification/alert';
5 | import { errorPropTypes } from '../../util/proptype-utils';
6 |
7 | const GenericForm = ({ formSpec = [], errors = [], message = '', onSubmit, submitText }) => (
8 |
16 | );
17 |
18 | GenericForm.propTypes = {
19 | onSubmit: PropTypes.func,
20 | formSpec: PropTypes.arrayOf(PropTypes.shape({
21 | placeholder: PropTypes.string,
22 | type: PropTypes.string,
23 | id: PropTypes.string,
24 | name: PropTypes.string,
25 | label: PropTypes.string,
26 | component: PropTypes.func,
27 | })),
28 | message: PropTypes.string,
29 | errors: errorPropTypes,
30 | submitText: PropTypes.string,
31 | };
32 |
33 | export default GenericForm;
34 |
--------------------------------------------------------------------------------
/api/constants.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ROLES: {
3 | ADMIN: 'admin',
4 | USER: 'user',
5 | },
6 | ERRORS: {
7 | ALREADY_REGISTERED: 'A user has already registered with that email address.',
8 | BAD_LOGIN: 'Your login details could not be verified. Please try again.',
9 | INVALID_EMAIL: 'You must enter a valid email address.',
10 | INVALID_ENTRY: 'You have not filled out all the required fields.',
11 | INVALID_NAME: 'You must enter a full name.',
12 | INVALID_PASSWORD: 'You must enter a password.',
13 | JWT_EXPIRED: 'For your safety, your session has expired. Please log back in and try your request again.',
14 | JWT_FAILURE: 'You are not authorized to access this content. If you feel this is in error, please contact an administrator.',
15 | NO_PERMISSION: 'You do not have permission to access this content.',
16 | PASSWORD_CONFIRM_FAIL: 'Your passwords did not match. Please attempt your request again.',
17 | PASSWORD_MUST_MATCH: 'Your passwords must match.',
18 | PASSWORD_RESET_EXPIRED: 'Your password reset request may have expired. Please attempt to reset your password again.',
19 | PASSWORD_TOO_SHORT: 'Your password must be at least eight characters long.',
20 | USER_NOT_FOUND: 'No user was found.',
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/app/src/util/environment-utils.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * getEnvironment - Returns the current environment, or development by default
4 | * @returns {String}
5 | */
6 | export const getEnvironment = () => process.env.NODE_ENV
7 | ? process.env.NODE_ENV
8 | : 'development';
9 |
10 |
11 | /**
12 | * getApiUrl - Returns the URL for the api, given the current environment
13 | * @returns {String}
14 | */
15 | export const getApiUrl = () => {
16 | switch (getEnvironment()) {
17 | case 'production':
18 | return 'http://api.mkrn-domain.com';
19 | case 'stage':
20 | return 'http://api-stage.mkrn-stage-domain.com';
21 | case 'test':
22 | return 'http://api-test.mkrn-test-domain.com';
23 | case 'development':
24 | default:
25 | return 'http://localhost:3000';
26 | }
27 | };
28 |
29 |
30 | /**
31 | * getAppUrl - Returns the URL for the app, given the environment
32 | * @returns {String}
33 | */
34 | export const getAppUrl = () => {
35 | switch (getEnvironment()) {
36 | case 'production':
37 | return 'http://app.mkrn-domain.com';
38 | case 'stage':
39 | return 'http://app-stage.mkrn-stage-domain.com';
40 | case 'test':
41 | return 'http://app-test.mkrn-test-domain.com';
42 | case 'development':
43 | default:
44 | return 'http://localhost:8080';
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/app/src/components/general/modal.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import './modal.scss';
4 |
5 | class Modal extends Component {
6 | static propTypes = {
7 | handleClose: PropTypes.func,
8 | heading: PropTypes.string,
9 | isOpen: PropTypes.bool,
10 | canScroll: PropTypes.bool,
11 | children: PropTypes.node,
12 | };
13 |
14 | componentDidMount = () => window.addEventListener('keyup', this.escapeClose);
15 | componentWillUnmount = () => window.removeEventListener('keyup', this.escapeClose);
16 |
17 | escapeClose = (e) => {
18 | if (e.which === 27 && this.props.isOpen) {
19 | this.props.handleClose(e);
20 | }
21 | }
22 |
23 | render() {
24 | const { handleClose, heading, isOpen, canScroll = false, children } = this.props;
25 | return (
26 |
27 |
28 |
29 |
32 |
{heading}
33 | {children}
34 |
35 |
36 |
37 | );
38 | }
39 | }
40 |
41 | export default Modal;
42 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/partials/_header.scss:
--------------------------------------------------------------------------------
1 | header {
2 | background: #e6e6e6;
3 | height: $header-height;
4 | width: 100%;
5 | padding: 0 $wrapper-horiz-pad;
6 |
7 | .mobile-nav-toggle {
8 | text-decoration: none;
9 | line-height: $header-height;
10 | color: #000;
11 | }
12 |
13 | .logo {
14 | line-height: $header-height;
15 | }
16 |
17 | // Navigation
18 | nav {
19 | // Mobile navigation
20 | &.mobile {
21 | display: none;
22 |
23 | &.is-expanded {
24 | display: block;
25 |
26 | ul {
27 | position: absolute;
28 | width: 100%;
29 | right: 0;
30 | top: $header-height;
31 | padding: 10px;
32 | background-color: #efefef;
33 |
34 | li {
35 | display: block;
36 | padding: 10px 0;
37 | line-height: 1rem;
38 | border-top: 1px solid #CCC;
39 |
40 | &:first-of-type {
41 | border-top: none;
42 | }
43 | }
44 | }
45 | }
46 | }
47 |
48 | // Non-mobile and shared navigation
49 | ul {
50 | margin: 0;
51 |
52 | li {
53 | display: inline-block;
54 | padding: 0 10px;
55 | line-height: $header-height;
56 |
57 | &:last-of-type {
58 | padding-right: 0;
59 | }
60 | }
61 | }
62 | }
63 | }
--------------------------------------------------------------------------------
/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mkrn-api",
3 | "version": "1.0.0",
4 | "description": "Koa-based API for MKRN starter.",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "nodemon index.js",
8 | "test": "jest --coverage --watch"
9 | },
10 | "repository": "https://github.com/joshuaslate/mkrn-starter.git",
11 | "author": "Joshua Anderson Slate",
12 | "license": "MIT",
13 | "dependencies": {
14 | "bcrypt": "^5.0.1",
15 | "crypto-promise": "^2.1.0",
16 | "jsonwebtoken": "^8.5.1",
17 | "kcors": "^2.2.2",
18 | "koa": "^2.13.1",
19 | "koa-bodyparser": "^4.3.0",
20 | "koa-combine-routers": "^2.0.1",
21 | "koa-logger": "^3.2.1",
22 | "koa-passport": "^4.1.4",
23 | "koa-router": "^10.0.0",
24 | "lodash": "^4.17.21",
25 | "mailgun-js": "^0.22.0",
26 | "moment": "^2.29.1",
27 | "mongoose": "^5.13.3",
28 | "passport-jwt": "^4.0.0",
29 | "passport-local": "^1.0.0",
30 | "stripe": "^8.165.0",
31 | "validator": "^13.6.0"
32 | },
33 | "devDependencies": {
34 | "@babel/eslint-parser": "^7.14.7",
35 | "eslint": "^7.31.0",
36 | "eslint-config-airbnb": "^18.2.1",
37 | "eslint-config-airbnb-base": "^14.2.1",
38 | "eslint-plugin-import": "^2.23.4",
39 | "eslint-plugin-jest": "^24.4.0",
40 | "eslint-plugin-jsx-a11y": "6.4.1",
41 | "eslint-plugin-react": "^7.24.0",
42 | "eslint-plugin-react-hooks": "^4.2.0",
43 | "jest": "^27.0.6",
44 | "nodemon": "^2.0.12",
45 | "supertest": "^6.1.4",
46 | "typescript": "^4.3.5"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/api/utils/user-utils.js:
--------------------------------------------------------------------------------
1 | const jwt = require('jsonwebtoken');
2 | const _ = require('lodash');
3 | const authConfig = require('../config').auth;
4 | const ROLES = require('../constants').ROLES;
5 |
6 | /**
7 | * standardizeUser - Standardizes user and strips unnecessary data
8 | * @param {Object} user Full user object
9 | * @returns {Object} Stripped down user information
10 | */
11 | const standardizeUser = user => ({
12 | id: _.get(user, '_id') || '',
13 | firstName: _.get(user, 'name.first') || '',
14 | lastName: _.get(user, 'name.last') || '',
15 | email: _.get(user, 'email') || '',
16 | role: _.get(user, 'role') || '',
17 | });
18 |
19 | /**
20 | * generateJWT - Signs JWT with user data
21 | * @param {Object} user Object containing user data to sign JWT with
22 | * @returns {Object} JSON Web Token for authenticated API requests
23 | */
24 | const generateJWT = user => ({
25 | token: jwt.sign(standardizeUser(user), authConfig.secret, {
26 | expiresIn: authConfig.jwtExpiration,
27 | }),
28 | expiration: authConfig.jwtExpiration,
29 | });
30 |
31 | /**
32 | * getRole - Returns a numerical value, which corresponds to the user's role
33 | * @param {String} role User's role in string form from the database
34 | * @returns {Number} User's role in number form for comparison
35 | */
36 | const getRole = (role) => {
37 | switch (role) {
38 | case ROLES.ADMIN: return 2;
39 | case ROLES.USER: return 1;
40 | default: return 0;
41 | }
42 | };
43 |
44 | module.exports = {
45 | generateJWT,
46 | getRole,
47 | standardizeUser,
48 | };
49 |
--------------------------------------------------------------------------------
/app/src/util/i18n.js:
--------------------------------------------------------------------------------
1 | import { translate, getTranslations } from 'react-i18nify';
2 |
3 | export const getClientLanguage = () => {
4 | const translations = getTranslations(),
5 | translatedlanguages = Object.keys(translations);
6 | let translatedRegex,
7 | clientLanguage;
8 |
9 | switch (translatedlanguages.length) {
10 | case 0:
11 | return undefined;
12 |
13 | case 1:
14 | translatedRegex = new RegExp(`^${translatedlanguages[0]}`);
15 | break;
16 |
17 | default:
18 | translatedRegex = translatedlanguages.reduce((accumulator, currentValue, index) =>
19 | index === 1 ? `(${accumulator})|(${currentValue})` : `${accumulator}|(${currentValue})`
20 | );
21 | translatedRegex = new RegExp(`^${translatedRegex}`);
22 | }
23 |
24 | if (navigator.languages) {
25 | clientLanguage = navigator.languages.find(
26 | language => translatedRegex.test(language));
27 |
28 | if (clientLanguage) {
29 | return translations[clientLanguage] ? clientLanguage : clientLanguage.substring(0, 2);
30 | }
31 | }
32 |
33 | clientLanguage = navigator.language || navigator.userLanguage;
34 |
35 | if (clientLanguage && translatedlanguages.find( language => clientLanguage.search(language) >= 0 || language.search(clientLanguage) >= 0)) {
36 | return translations[clientLanguage] ? clientLanguage : clientLanguage.substring(0, 2);
37 | }
38 |
39 | return undefined;
40 | }
41 |
42 | export const getComponentTranslator = (name) => {
43 | return (key, parameters) => {
44 | return translate(name + '.' + key, parameters);
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/app/webpack.config.js:
--------------------------------------------------------------------------------
1 | const polyfill = require('babel-polyfill');
2 | const path = require('path');
3 | const HotModuleReplacementPlugin = require('webpack').HotModuleReplacementPlugin;
4 |
5 | module.exports = () => ({
6 | entry: [
7 | 'babel-polyfill',
8 | 'react-hot-loader/patch',
9 | 'webpack-dev-server/client?http://localhost:8080',
10 | path.join(__dirname, 'src/index.js'),
11 | ],
12 | output: {
13 | path: path.join(__dirname, 'dist'),
14 | filename: 'bundle.js'
15 | },
16 | devtool: 'eval-source-map',
17 | plugins: [
18 | new HotModuleReplacementPlugin(),
19 | ],
20 | module: {
21 | rules: [
22 | {
23 | test: /\.js$/,
24 | exclude: /node_modules/,
25 | include: path.join(__dirname, 'src'),
26 | use: [
27 | {
28 | loader: 'babel-loader',
29 | options: {
30 | babelrc: false,
31 | presets: [
32 | ['@babel/preset-env', { modules: false }],
33 | '@babel/react',
34 | ],
35 | plugins: ['react-hot-loader/babel'],
36 | },
37 | },
38 | ],
39 | },
40 | {
41 | test: /\.(css|scss)$/,
42 | loader: 'style-loader',
43 | },
44 | {
45 | test: /\.(css|scss)$/,
46 | loader: 'css-loader',
47 | },
48 | {
49 | test: /\.(css|scss)$/,
50 | loader: 'sass-loader',
51 | },
52 | ],
53 | },
54 | devServer: {
55 | historyApiFallback: true,
56 | contentBase: './src',
57 | hot: true,
58 | },
59 | });
60 |
--------------------------------------------------------------------------------
/app/src/redux/modules/billing.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { APP_NAMESPACE } from '../../util/redux-constants';
3 | import { put, post, get, del } from '../../util/http-utils';
4 | import { updateStore, buildGenericInitialState, handleError } from '../../util/store-utils';
5 |
6 | const BILLING_ENDPOINT_BASE = 'billing';
7 | const typeBase = `${APP_NAMESPACE}/${BILLING_ENDPOINT_BASE}/`;
8 |
9 | // Constants
10 | export const CREATE_SUBSCRIPTION = `${typeBase}CREATE_SUBSCRIPTION`;
11 |
12 | // Actions
13 |
14 | /**
15 | * createSubscription - Create a new Stripe subscription
16 | * @param {Object} formData {stripeToken, plan, isTrial, quantity}
17 | */
18 | export const createSubscription = formData => async (dispatch) => {
19 | try {
20 | await post(dispatch, CREATE_SUBSCRIPTION, `${BILLING_ENDPOINT_BASE}/subscription`, formData, true);
21 | } catch (err) {
22 | await handleError(dispatch, err, CREATE_SUBSCRIPTION);
23 | }
24 | };
25 |
26 | // Store
27 | const INITIAL_STATE = {
28 | ...buildGenericInitialState([CREATE_SUBSCRIPTION]),
29 | id: '',
30 | sources: [],
31 | subscriptions: [],
32 | invoices: [],
33 | };
34 |
35 | export default (state = INITIAL_STATE, action) => {
36 | switch (action.type) {
37 | case CREATE_SUBSCRIPTION:
38 | return updateStore(state, action, _.get(action, 'payload.customer')
39 | ? {
40 | id: action.payload.customer.id,
41 | sources: _.get(action, 'payload.customer.sources.data') || [],
42 | subscriptions: _.get(action, 'payload.customer.subscriptions.data') || [],
43 | }
44 | : {});
45 | default:
46 | return state;
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MKRN Starter
2 | Starter/seed project for MongoDB, Koa, React/Redux, Node full-stack JavaScript apps.
3 |
4 | ## Usage
5 | Clone the starter onto your machine and install all of the dependencies. You may need to [install a local copy of mongodb](https://docs.mongodb.com/manual/installation/) if you do not already have it.
6 |
7 | This example shows cloning the main repository, but you should **fork** it first and clone your fork if you plan to contribute.
8 |
9 | ```
10 | git clone https://github.com/joshuaslate/mkrn-starter.git
11 | cd mkrn-starter/api
12 | npm install
13 | cd ../mkrn-starter/app
14 | npm install
15 | ```
16 | Now, you need to set up three shells to run mongodb, the server, and the client.
17 |
18 | *shell 1 - this is mongodb*
19 |
20 | ```
21 | cd ../mkrn-starter/app
22 | mongod
23 | ```
24 | *shell 2 - this is the server*
25 |
26 | ```
27 | cd ../mkrn-starter/app
28 | npm start
29 | ```
30 | *shell 3 - this is your client*
31 |
32 | ```
33 | cd ../mkrn-starter/api
34 | npm start
35 | ```
36 |
37 | At this point, you should be able to navigate to `http://localhost:8080/` on your browser (if it doesn't open automatically) and see the landing page which is blank except for the header. Recommendations:
38 |
39 | - open your 'Developer Console'
40 | - Click on "Register" and enter a new user
41 | - you should be navigated to the protected area
42 | - test the Sign Out, forgot password, and other features
43 | - break it, learn from it, post here :green_heart:
44 |
45 | ## Features
46 | - Login/Logout
47 | - Register
48 | - Forgot passwort
49 | - Multiple languages (en, de)
50 |
51 | ## Contributions
52 | Please feel free to contribute to this project. Whether it's features, tests, or code cleanup, any help is welcome at this point.
53 |
54 | ### Contact
55 | Please send inquiries to josh(at)slatepeak.com, or use the contact form at https://slatepeak.com to contact me.
56 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mkrn-app",
3 | "version": "1.0.0",
4 | "description": "React/Redux client-side for MKRN stack app.",
5 | "main": "index.js",
6 | "author": "Joshua Anderson Slate",
7 | "license": "MIT",
8 | "scripts": {
9 | "start": "webpack serve --progress --color --hot --mode development",
10 | "build": "export NODE_ENV=production|| set NODE_ENV=production && webpack"
11 | },
12 | "devDependencies": {
13 | "@babel/core": "^7.14.8",
14 | "@babel/eslint-parser": "^7.14.7",
15 | "@babel/preset-env": "^7.14.8",
16 | "@babel/preset-react": "^7.14.5",
17 | "@babel/preset-stage-2": "^7.8.3",
18 | "babel-loader": "^8.2.2",
19 | "css-loader": "^6.2.0",
20 | "eslint": "7.31.0",
21 | "eslint-config-airbnb": "18.2.1",
22 | "eslint-config-airbnb-base": "^14.2.1",
23 | "eslint-plugin-import": "2.23.4",
24 | "eslint-plugin-jest": "^24.4.0",
25 | "eslint-plugin-jsx-a11y": "6.4.1",
26 | "eslint-plugin-react": "7.24.0",
27 | "eslint-plugin-react-hooks": "^4.2.0",
28 | "node-sass": "^6.0.1",
29 | "react-hot-loader": "4.13.0",
30 | "sass-loader": "^12.1.0",
31 | "style-loader": "^3.2.1",
32 | "typescript": "^3.9.10",
33 | "webpack-cli": "^4.7.2"
34 | },
35 | "dependencies": {
36 | "@babel/polyfill": "^7.12.1",
37 | "axios": "^0.21.2",
38 | "babel-polyfill": "^6.26.0",
39 | "lodash": "^4.17.21",
40 | "moment": "^2.29.1",
41 | "normalize.css": "^8.0.1",
42 | "path": "^0.12.7",
43 | "prop-types": "^15.7.2",
44 | "react": "^17.0.2",
45 | "react-dom": "^17.0.2",
46 | "react-i18nify": "^4.1.0",
47 | "react-redux": "^7.2.4",
48 | "react-router-dom": "^5.2.0",
49 | "redux": "^4.1.0",
50 | "redux-form": "^8.3.7",
51 | "redux-thunk": "^2.3.0",
52 | "universal-cookie": "^4.0.4",
53 | "webpack": "^5.46.0",
54 | "webpack-dev-server": "^3.11.2"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/api/controllers/user.js:
--------------------------------------------------------------------------------
1 | const User = require('../models/user');
2 | const userUtils = require('../utils/user-utils');
3 | const validationUtils = require('../utils/validation-utils');
4 |
5 | const { standardizeUser } = userUtils;
6 | const { filterSensitiveData } = validationUtils;
7 |
8 | /**
9 | * getUsers - Returns JSON for all users
10 | * @returns {Array} - Array of users
11 | */
12 | exports.getUsers = async (ctx, next) => {
13 | try {
14 | const users = await User.find({});
15 | const filteredUsers = users.map(user => standardizeUser(user));
16 | ctx.status = 200;
17 | ctx.body = { users: filteredUsers };
18 | await next();
19 | } catch (err) {
20 | ctx.throw(500, err);
21 | }
22 | };
23 |
24 | /**
25 | * getUser - Returns JSON for specified user
26 | * @returns {Object} - Single user object
27 | */
28 | exports.getUser = async (ctx, next) => {
29 | try {
30 | const user = await User.findById(ctx.params.id);
31 | ctx.status = 200;
32 | ctx.body = { user: standardizeUser(user) };
33 | await next();
34 | } catch (err) {
35 | ctx.throw(500, err);
36 | }
37 | };
38 |
39 | /**
40 | * editUser - Edits single user
41 | */
42 | exports.editUser = async (ctx, next) => {
43 | try {
44 | // Allow users to edit all of their own information, but limited information
45 | // on other users. This could be controlled in other ways as well.
46 | const safeData = ctx.state.user.id === ctx.params.id
47 | ? ctx.request.body
48 | : filterSensitiveData(ctx.request.body);
49 |
50 | await User.findOneAndUpdate({ _id: ctx.params.id }, safeData);
51 | ctx.body = { user: safeData };
52 | await next();
53 | } catch (err) {
54 | ctx.throw(500, err);
55 | }
56 | };
57 |
58 | /**
59 | * deleteUser - Deletes single user
60 | */
61 | exports.deleteUser = async (ctx, next) => {
62 | try {
63 | await User.findOneAndRemove({ _id: ctx.params.id });
64 | await next();
65 | } catch (err) {
66 | ctx.throw(500, err);
67 | }
68 | };
69 |
--------------------------------------------------------------------------------
/app/src/components/hoc/require-auth.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import _ from 'lodash';
4 | import { connect } from 'react-redux';
5 | import { withRouter } from 'react-router-dom';
6 | import { getAuthenticatedUser, setPostAuthPath } from '../../redux/modules/authentication';
7 |
8 | export default (ComposedComponent) => {
9 | class Authentication extends Component {
10 | static propTypes = {
11 | authenticated: PropTypes.bool,
12 | history: PropTypes.shape({
13 | push: PropTypes.func,
14 | }),
15 | match: PropTypes.shape({
16 | path: PropTypes.string,
17 | }),
18 | setPostAuthPath: PropTypes.func,
19 | getAuthenticatedUser: PropTypes.func,
20 | };
21 |
22 | // List of pre-authention routes, so they aren't saved for a post-auth redirect
23 | static preAuthRoutes = ['/login', '/register', '/reset-password', '/forgot-password'];
24 |
25 | componentDidMount = () => this.ensureAuthentication(this.props.authenticated);
26 |
27 | componentWillUpdate = (nextProps) => {
28 | if (this.props.authenticated !== nextProps.authenticated) {
29 | this.ensureAuthentication(nextProps.authenticated);
30 | }
31 | };
32 |
33 | ensureAuthentication = (isAuthed) => {
34 | if (!isAuthed) {
35 | const path = _.get(this.props.match, 'path');
36 |
37 | // Save the user's path for future redirect
38 | if (path && !Authentication.preAuthRoutes.includes(path)) {
39 | this.props.setPostAuthPath(path);
40 | }
41 |
42 | // Redirect to the login page
43 | return this.props.history.push('/login');
44 | }
45 |
46 | return this.props.getAuthenticatedUser();
47 | }
48 |
49 | render() {
50 | return ;
51 | }
52 | }
53 |
54 | const mapStateToProps = ({ authentication }) => ({ authenticated: authentication.authenticated });
55 |
56 | return withRouter(connect(mapStateToProps, { getAuthenticatedUser, setPostAuthPath })(Authentication));
57 | };
58 |
--------------------------------------------------------------------------------
/app/src/components/billing/credit-card-fields.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { STRIPE_PUBLIC_KEY } from '../../constants/key-constants';
4 |
5 | class CreditCardFields extends Component {
6 | static propTypes = {
7 | label: PropTypes.string,
8 | onSubmit: PropTypes.func,
9 | plan: PropTypes.string,
10 | quantity: PropTypes.number,
11 | };
12 |
13 | constructor(props) {
14 | super(props);
15 |
16 | // Set up Stripe form
17 | this.stripe = Stripe(STRIPE_PUBLIC_KEY);
18 | this.elements = this.stripe.elements();
19 | this.card = this.elements.create('card');
20 |
21 | this.state = {
22 | error: '',
23 | };
24 | }
25 |
26 | componentDidMount = () => {
27 | // Mount Stripe elements
28 | this.card.mount('#card-element');
29 |
30 | // Set up error handling
31 | this.card.addEventListener('change', (e) => {
32 | if (e.error) {
33 | return this.setState({ error: e.error });
34 | }
35 |
36 | return this.setState({ error: '' });
37 | });
38 | };
39 |
40 | onSubmit = (e) => {
41 | e.preventDefault();
42 | this.setState({ error: '' });
43 |
44 | return this.stripe.createToken(this.card)
45 | .then((result) => {
46 | if (result.error) {
47 | return this.setState({ error: result.error });
48 | }
49 |
50 | // Otherwise, pass pertinent information to the onSubmit function
51 | const billingResults = {
52 | plan: this.props.plan,
53 | quantity: this.props.quantity,
54 | stripeToken: result.token.id,
55 | lastFour: result.token.card.last4,
56 | };
57 |
58 | return this.props.onSubmit(billingResults);
59 | });
60 | }
61 |
62 | render() {
63 | return (
64 |
75 | );
76 | }
77 | }
78 |
79 | export default CreditCardFields;
80 |
--------------------------------------------------------------------------------
/app/src/components/authentication/forgot-password.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { reduxForm } from 'redux-form';
5 | import { Link } from 'react-router-dom';
6 | import TextInput from '../form-fields/text-input';
7 | import GenericForm from '../form-fields/generic-form';
8 | import { forgotPassword, RESET_PASSWORD } from '../../redux/modules/authentication';
9 | import { errorPropTypes } from '../../util/proptype-utils';
10 | import './authentication.scss';
11 | import { getComponentTranslator } from '../../util/i18n';
12 | import { Translate } from 'react-i18nify';
13 |
14 | const translate = getComponentTranslator('forgotPassword');
15 |
16 | const form = reduxForm({
17 | form: 'forgotPassword',
18 | });
19 |
20 | class ForgotPassword extends Component {
21 | static propTypes = {
22 | forgotPassword: PropTypes.func,
23 | handleSubmit: PropTypes.func,
24 | errors: errorPropTypes,
25 | message: PropTypes.string,
26 | loading: PropTypes.bool,
27 | };
28 |
29 | static formSpec = [
30 | { id: 'email', name: 'email', label: translate('email'), type: 'email', placeholder: 'you@yourdomain.com', component: TextInput },
31 | ];
32 |
33 | handleFormSubmit = formProps => this.props.forgotPassword(formProps);
34 |
35 | render() {
36 | const { handleSubmit, errors, message, loading } = this.props;
37 | return (
38 |
39 |
40 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | const mapStateToProps = ({ authentication }) => ({
54 | errors: authentication.errors[RESET_PASSWORD],
55 | message: authentication.messages[RESET_PASSWORD],
56 | loading: authentication.loading[RESET_PASSWORD],
57 | });
58 |
59 | export default connect(mapStateToProps, { forgotPassword })(form(ForgotPassword));
60 |
--------------------------------------------------------------------------------
/app/src/redux/modules/user.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { APP_NAMESPACE } from '../../util/redux-constants';
3 | import { put, post, get, del } from '../../util/http-utils';
4 | import { updateStore, buildGenericInitialState, handleError } from '../../util/store-utils';
5 | import { CHANGE_AUTH, GET_AUTHENTICATED_USER } from './authentication';
6 |
7 | const USER_ENDPOINT_BASE = 'user';
8 | const typeBase = `${APP_NAMESPACE}/${USER_ENDPOINT_BASE}/`;
9 |
10 | // Constants
11 | export const GET_USER = `${typeBase}GET_USER`;
12 | export const GET_USERS = `${typeBase}GET_USERS`;
13 |
14 | // Actions
15 |
16 | /**
17 | * getUser - Fetches user from API, given id
18 | *
19 | * @param {String} id User's id for lookup
20 | * @returns {Promise}
21 | */
22 | export const getUser = id => async (dispatch) => {
23 | try {
24 | const response = await get(dispatch, GET_USER, `${USER_ENDPOINT_BASE}/${id}`, true);
25 | return Promise.resolve(response);
26 | } catch (err) {
27 | await handleError(dispatch, err, GET_USER);
28 | }
29 | };
30 |
31 | /**
32 | * getUsers - Fetches users from API
33 | *
34 | * @returns {Promise}
35 | */
36 | export const getUsers = () => async (dispatch) => {
37 | try {
38 | const response = await get(dispatch, GET_USERS, USER_ENDPOINT_BASE, true);
39 | return Promise.resolve(response);
40 | } catch (err) {
41 | await handleError(dispatch, err, GET_USER);
42 | }
43 | };
44 |
45 | // Store
46 | const INITIAL_STATE = {
47 | ...buildGenericInitialState([GET_USER, GET_USERS]),
48 | };
49 |
50 | export default (state = INITIAL_STATE, action) => {
51 | switch (action.type) {
52 | case CHANGE_AUTH:
53 | return updateStore(state, action, _.get(action, 'payload.user.id') ? { [action.payload.user.id]: action.payload.user } : {});
54 | case GET_USER:
55 | case GET_AUTHENTICATED_USER:
56 | return updateStore(state, action, _.get(action, 'payload.user.id') ? { [action.payload.user.id]: action.payload.user } : {});
57 | case GET_USERS:
58 | return updateStore(state, action, _.get(action, 'payload.users') ? _.mapKeys(action.payload.users, 'id') : {});
59 | default:
60 | return state;
61 | }
62 | };
63 |
64 | // Selectors
65 | export const getAuthenticatedUser = ({ user, authentication }) => user[authentication.user];
66 |
--------------------------------------------------------------------------------
/app/src/components/authentication/reset-password.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { reduxForm } from 'redux-form';
5 | import TextInput from '../form-fields/text-input';
6 | import GenericForm from '../form-fields/generic-form';
7 | import { resetPassword, RESET_PASSWORD } from '../../redux/modules/authentication';
8 | import { errorPropTypes } from '../../util/proptype-utils';
9 | import './authentication.scss';
10 | import { getComponentTranslator } from '../../util/i18n';
11 | import { Translate } from 'react-i18nify';
12 |
13 | const translate = getComponentTranslator('resetPassword');
14 |
15 | const form = reduxForm({
16 | form: 'resetPassword',
17 | });
18 |
19 | class ResetPassword extends Component {
20 | static propTypes = {
21 | resetPassword: PropTypes.func,
22 | handleSubmit: PropTypes.func,
23 | errors: errorPropTypes,
24 | message: PropTypes.string,
25 | loading: PropTypes.bool,
26 | params: PropTypes.shape({
27 | token: PropTypes.string,
28 | }),
29 | };
30 |
31 | static formSpec = [
32 | { id: 'password', name: 'password', label: translate('password'), type: 'password', placeholder: '********', component: TextInput },
33 | { id: 'passwordConfirm', name: 'passwordConfirm', label: translate('confirmPassword'), type: 'password', placeholder: '********', component: TextInput },
34 | ];
35 |
36 | handleFormSubmit = formProps => this.props.resetPassword(formProps, this.props.params.token);
37 |
38 | render() {
39 | const { handleSubmit, errors, message, loading } = this.props;
40 | return (
41 |
42 |
43 |
50 |
51 | );
52 | }
53 | }
54 |
55 | const mapStateToProps = ({ authentication }) => ({
56 | errors: authentication.errors[RESET_PASSWORD],
57 | message: authentication.messages[RESET_PASSWORD],
58 | loading: authentication.loading[RESET_PASSWORD],
59 | });
60 |
61 | export default connect(mapStateToProps, { resetPassword })(form(ResetPassword));
62 |
--------------------------------------------------------------------------------
/api/models/user.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | const bcrypt = require('bcrypt');
3 | const ROLES = require('../constants').ROLES;
4 |
5 | const Schema = mongoose.Schema;
6 |
7 | //= ===============================
8 | // User Schema
9 | //= ===============================
10 | const UserSchema = new Schema({
11 | email: {
12 | type: String,
13 | lowercase: true,
14 | unique: true,
15 | required: true,
16 | },
17 | password: { type: String, required: true },
18 | name: {
19 | first: { type: String, required: true },
20 | last: { type: String, required: true },
21 | },
22 | role: {
23 | type: String,
24 | enum: Object.keys(ROLES).map(key => ROLES[key]),
25 | default: ROLES.USER,
26 | },
27 | resetPasswordToken: { type: String },
28 | resetPasswordExpires: { type: Date },
29 | billing: {
30 | customerId: { type: String },
31 | subscriptionId: { type: String },
32 | plan: { type: String },
33 | nextPaymentDue: { type: Date },
34 | },
35 | deactivated: { type: Boolean, default: false },
36 | },
37 | {
38 | timestamps: true,
39 | toObject: {
40 | virtuals: true,
41 | },
42 | toJSON: {
43 | virtuals: true,
44 | },
45 | });
46 |
47 | UserSchema.virtual('fullName').get(function virtualFullName() {
48 | return `${this.name.first} ${this.name.last}`;
49 | });
50 |
51 | //= ===============================
52 | // User model hooks
53 | //= ===============================
54 | async function hashPassword(next) {
55 | const user = this;
56 |
57 | if (user && user.isModified('password')) {
58 | try {
59 | const salt = await bcrypt.genSalt(5);
60 | user.password = await bcrypt.hash(user.password, salt, null);
61 | return next();
62 | } catch (err) {
63 | return next(err);
64 | }
65 | } else {
66 | return next();
67 | }
68 | }
69 |
70 | // Pre-save of user to database, hash password if password is modified or new
71 | UserSchema.pre('save', hashPassword);
72 | UserSchema.pre('update', hashPassword);
73 |
74 | //= ===============================
75 | // User model methods
76 | //= ===============================
77 | // Method to compare password for login
78 | UserSchema.methods.comparePassword = async function comparePassword(candidatePassword) {
79 | try {
80 | return await bcrypt.compare(candidatePassword, this.password);
81 | } catch (err) {
82 | throw new Error(err);
83 | }
84 | };
85 |
86 | module.exports = mongoose.model('User', UserSchema);
87 |
--------------------------------------------------------------------------------
/app/src/components/authentication/login.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { reduxForm } from 'redux-form';
5 | import { Link } from 'react-router-dom';
6 | import TextInput from '../form-fields/text-input';
7 | import GenericForm from '../form-fields/generic-form';
8 | import { login, CHANGE_AUTH } from '../../redux/modules/authentication';
9 | import { errorPropTypes } from '../../util/proptype-utils';
10 | import './authentication.scss';
11 | import { getComponentTranslator } from '../../util/i18n';
12 | import { Translate } from 'react-i18nify';
13 |
14 | const translate = getComponentTranslator('login');
15 |
16 | const form = reduxForm({
17 | form: 'login',
18 | });
19 |
20 | class Login extends Component {
21 | static propTypes = {
22 | handleSubmit: PropTypes.func,
23 | desiredPath: PropTypes.string,
24 | login: PropTypes.func,
25 | errors: errorPropTypes,
26 | message: PropTypes.string,
27 | loading: PropTypes.bool,
28 | };
29 |
30 | static formSpec = [
31 | { id: 'email', name: 'email', label: translate('email'), type: 'email', placeholder: 'you@yourdomain.com', component: TextInput },
32 | { id: 'password', name: 'password', label: translate('password'), type: 'password', placeholder: '********', component: TextInput },
33 | ];
34 |
35 | handleFormSubmit = (formProps) => {
36 | const { desiredPath } = this.props;
37 | if (desiredPath) {
38 | this.props.login(formProps, desiredPath);
39 | } else {
40 | this.props.login(formProps);
41 | }
42 | }
43 |
44 | render = () => {
45 | const { handleSubmit, errors, message, loading } = this.props;
46 |
47 | return (
48 |
49 |
50 |
57 |
58 | |
59 |
60 |
61 |
62 | );
63 | }
64 | }
65 |
66 | const mapStateToProps = ({ authentication }) => ({
67 | errors: authentication.errors[CHANGE_AUTH],
68 | message: authentication.messages[CHANGE_AUTH],
69 | loading: authentication.loading[CHANGE_AUTH],
70 | authenticated: authentication.authenticated,
71 | desiredPath: authentication.desiredPath,
72 | });
73 |
74 | export default connect(mapStateToProps, { login })(form(Login));
75 |
--------------------------------------------------------------------------------
/api/test/user.test.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 | const server = require('../index');
3 | const User = require('../models/user');
4 | const ERRORS = require('../constants').ERRORS;
5 |
6 | const seedUser = {
7 | email: 'testSuccess@successfultestdomain.com',
8 | password: '12345678',
9 | name: {
10 | first: 'Jillian',
11 | last: 'Mary',
12 | },
13 | };
14 |
15 | const validRegistration = {
16 | email: 'test@domain.com',
17 | password: '12345678',
18 | name: {
19 | first: 'Kurt',
20 | last: 'Vonnegut',
21 | },
22 | };
23 |
24 | describe('User tests', () => {
25 | beforeAll(async () => {
26 | await new User(seedUser).save();
27 | });
28 |
29 | it('should not allow a user to register with missing information', async () => {
30 | const fields = Object.keys(validRegistration);
31 |
32 | fields.forEach(async (field) => {
33 | const invalidRegistration = Object.assign({}, validRegistration);
34 | delete invalidRegistration[field];
35 |
36 | const result = await request(server).post('/user/register').send(invalidRegistration);
37 | result.expect(422);
38 | });
39 | });
40 |
41 | it('should not allow a user to register with a password fewer than 8 characters long', () => {
42 | const invalidRegistration = Object.assign({}, validRegistration, { password: '1234' });
43 | return request(server).post('/user/register')
44 | .send(invalidRegistration)
45 | .expect(422)
46 | .expect({ errors: [{ error: ERRORS.PASSWORD_TOO_SHORT }] });
47 | });
48 |
49 | it('should not allow a user to register with a bad email address', () => {
50 | const invalidRegistration = Object.assign({}, validRegistration, { email: 'bad email' });
51 | return request(server).post('/user/register')
52 | .send(invalidRegistration)
53 | .expect(422)
54 | .expect({ errors: [{ error: ERRORS.INVALID_EMAIL }] });
55 | });
56 |
57 | it('should not allow a user to register with a duplicate email address', () => {
58 | const invalidRegistration = Object.assign({}, validRegistration, { email: seedUser.email });
59 | return request(server).post('/user/register')
60 | .send(invalidRegistration)
61 | .expect(422)
62 | .expect({ errors: [{ error: ERRORS.ALREADY_REGISTERED }] });
63 | });
64 |
65 | it('should allow new users to register with valid credentials', async () => {
66 | await request(server).post('/user/register')
67 | .send(validRegistration)
68 | .expect(200);
69 | });
70 |
71 | it('should allow users with the correct login information to authenticate', async () => {
72 | await request(server).post('/user/login')
73 | .send(Object.assign({}, { email: seedUser.email, password: seedUser.password }))
74 | .expect(200);
75 | });
76 |
77 | // Remove saved user data from test database
78 | afterAll(() => User.remove({}));
79 | });
80 |
--------------------------------------------------------------------------------
/app/src/components/authentication/register.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { reduxForm } from 'redux-form';
5 | import { Link } from 'react-router-dom';
6 | import TextInput from '../form-fields/text-input';
7 | import GenericForm from '../form-fields/generic-form';
8 | import { register, CHANGE_AUTH } from '../../redux/modules/authentication';
9 | import { errorPropTypes } from '../../util/proptype-utils';
10 | import './authentication.scss';
11 | import { getComponentTranslator } from '../../util/i18n';
12 | import { Translate } from 'react-i18nify';
13 |
14 | const translate = getComponentTranslator('register');
15 |
16 | const form = reduxForm({
17 | form: 'register',
18 | });
19 |
20 | class Register extends Component {
21 | static propTypes = {
22 | handleSubmit: PropTypes.func,
23 | register: PropTypes.func,
24 | errors: errorPropTypes,
25 | message: PropTypes.string,
26 | loading: PropTypes.bool,
27 | };
28 |
29 | static formSpec = [
30 | { id: 'firstName', name: 'name.first', label: translate('firstName'), type: 'text', placeholder: translate('firstNameExample'), component: TextInput },
31 | { id: 'lastName', name: 'name.last', label: translate('lastName'), type: 'text', placeholder: translate('lastNameExample'), component: TextInput },
32 | { id: 'email', name: 'email', label: translate('email'), type: 'email', placeholder: translate('emailExample'), component: TextInput },
33 | { id: 'password', name: 'password', label: translate('password'), type: 'password', placeholder: '********', component: TextInput },
34 | { id: 'passwordConfirm', name: 'passwordConfirm', label: translate('confirmPassword'), type: 'password', placeholder: '********', component: TextInput },
35 | ];
36 |
37 | handleFormSubmit = formProps => this.props.register(formProps);
38 |
39 | render = () => {
40 | const { handleSubmit, errors, message, loading } = this.props;
41 |
42 | return (
43 |
44 |
45 |
52 |
53 |
54 | );
55 | }
56 | }
57 |
58 | const mapStateToProps = ({ authentication }) => ({
59 | errors: authentication.errors[CHANGE_AUTH],
60 | message: authentication.messages[CHANGE_AUTH],
61 | loading: authentication.loading[CHANGE_AUTH],
62 | authenticated: authentication.authenticated,
63 | });
64 |
65 | export default connect(mapStateToProps, { register })(form(Register));
66 |
--------------------------------------------------------------------------------
/api/utils/validation-utils.js:
--------------------------------------------------------------------------------
1 | const validator = require('validator');
2 | const ERRORS = require('../constants').ERRORS;
3 |
4 | /**
5 | * responseValidator - Validate responses and return corresponding errors
6 | *
7 | * @param {Object} req the Koa request body
8 | * @param {Array} fields an array of fields on the req to validate
9 | * @return {Object} validated/sanitized request body
10 | */
11 | const responseValidator = function responseValidator(req, fields) {
12 | const errors = [];
13 | const props = Object.keys(req);
14 |
15 | for (let i = 0; i < fields.length; i += 1) {
16 | const isPresent = props.indexOf(fields[i].name) !== -1;
17 | const isRequired = fields[i].required;
18 |
19 | if (!isPresent && isRequired) {
20 | switch (fields[i].name) {
21 | case 'email': errors.push({ error: ERRORS.INVALID_EMAIL }); break;
22 | case 'name':
23 | if (!req.name || !req.name.first || !req.name.last) {
24 | errors.push({ error: ERRORS.INVALID_NAME });
25 | }
26 | break;
27 | case 'password': errors.push({ error: ERRORS.INVALID_PASSWORD }); break;
28 | case 'passwordConfirm': errors.push({ error: ERRORS.PASSWORD_MUST_MATCH }); break;
29 | default: errors.push({ error: ERRORS.INVALID_ENTRY }); break;
30 | }
31 | } else {
32 | // Escape and sanitize inputs for security (validator only works on strings)
33 | if (typeof req[fields[i].name] === 'string') {
34 | req[fields[i].name] = validator.trim(req[fields[i].name]);
35 | // Evidently, React already escapes strings
36 | // req[fields[i].name] = validator.escape(req[fields[i].name]);
37 | }
38 |
39 | if (fields[i].name === 'email') {
40 | if (!validator.isEmail(req.email)) {
41 | errors.push({ error: ERRORS.INVALID_EMAIL });
42 | }
43 | }
44 | if (fields[i].name === 'password') {
45 | if (req.password && req.password.length < 8) {
46 | errors.push({ error: ERRORS.PASSWORD_TOO_SHORT });
47 | }
48 | }
49 | if (fields[i].name === 'passwordConfirm') {
50 | if (req.passwordConfirm !== req.password) {
51 | errors.push({ error: ERRORS.PASSWORD_MUST_MATCH });
52 | }
53 | }
54 | }
55 | }
56 |
57 | // If there are errors, return them, otherwise return the modified request body.
58 | if (errors && errors.length) {
59 | return errors;
60 | }
61 | return req;
62 | };
63 |
64 | /**
65 | * filterSensitiveData - Filters out sensitive data from a request body
66 | *
67 | * @param {Object} req the Koa request body
68 | * @return {Object} Body without sensitive fields
69 | */
70 | const filterSensitiveData = (req) => {
71 | const newBody = {};
72 | const sensitiveKeys = [
73 | 'password',
74 | 'billing',
75 | ];
76 |
77 | Object.keys(req).forEach((item) => {
78 | if (sensitiveKeys.indexOf(item) === -1) {
79 | newBody[item] = req[item];
80 | }
81 | });
82 |
83 | return newBody;
84 | };
85 |
86 | module.exports = {
87 | responseValidator,
88 | filterSensitiveData,
89 | };
90 |
--------------------------------------------------------------------------------
/app/src/i18n.js:
--------------------------------------------------------------------------------
1 | export default {
2 | en: {
3 | dashboard: {
4 | welcomeText: "Welcome to the dashboard"
5 | },
6 | forgotPassword: {
7 | backToLogin: "Back to login",
8 | email: "Email",
9 | submit: "Reset Password",
10 | title: "Forgot Password"
11 | },
12 | header: {
13 | dashboard: 'Dashboard',
14 | login: "Sign in",
15 | logout: "Sign out",
16 | profile: "Profile",
17 | register: "Register"
18 | },
19 | login: {
20 | email: "Email",
21 | createNewAccount: "Create a new account.",
22 | forgotPassword: "Forgot password?",
23 | submit: "Login",
24 | title: "Login",
25 | password: "Password"
26 | },
27 | register: {
28 | confirmPassword: "Confirm Password",
29 | email: 'Email',
30 | emailExample: 'you@yourdomain.com',
31 | firstName: "First Name",
32 | firstNameExample: "John",
33 | haveAccount: "Have an account?",
34 | lastName: "Last Name",
35 | lastNameExample: "Snow",
36 | password: "Password",
37 | submit: "Register",
38 | title: "Register"
39 | },
40 | resetPassword: {
41 | confirmPassword: "Confirm Password",
42 | password: "Password",
43 | submit: "Change Password",
44 | title: "Reset Password"
45 | }
46 | },
47 | de: {
48 | dashboard: {
49 | welcomeText: "Willkommen im Dashboard"
50 | },
51 | forgotPassword: {
52 | backToLogin: "Zurück zur Anmeldung",
53 | email: "Email",
54 | submit: "Kennwort zurücksetzen",
55 | title: "Kennwort vergessen"
56 | },
57 | header: {
58 | dashboard: 'Dashboard',
59 | login: "Anmelden",
60 | logout: "Abmelden",
61 | profile: "Profil",
62 | register: "Registrieren"
63 | },
64 | login: {
65 | email: "Email",
66 | createNewAccount: "Neues Konto anlegen.",
67 | forgotPassword: "Passwort vergessen?",
68 | submit: "Anmelden",
69 | title: "Anmelden",
70 | password: "Kennwort"
71 | },
72 | register: {
73 | confirmPassword: "Kennwort wiederholen",
74 | email: 'Email',
75 | emailExample: 'max.mustermann@yourdomain.com',
76 | firstName: "Vorname",
77 | firstNameExample: "Max",
78 | haveAccount: "Schon registriert?",
79 | lastName: "Nachname",
80 | lastNameExample: "Mustermann",
81 | password: "Kennwort",
82 | submit: "Registrieren",
83 | title: "Registrieren"
84 | },
85 | resetPassword: {
86 | confirmPassword: "Kennwort wiederholen",
87 | password: "Kennwort",
88 | submit: "Kennwort ändern",
89 | title: "Kennwort zurücksetzen"
90 | }
91 | }
92 | };
--------------------------------------------------------------------------------
/app/src/util/store-utils.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { PENDING, SUCCESS, ERROR } from './redux-constants';
3 |
4 | /**
5 | * updateStore - Returns an object containing updated state. This helper
6 | * builds generic state (messages, errors, loading)
7 | *
8 | * @param {Object} state Current state of the store
9 | * @param {Object} action Redux action for the store to respond to
10 | * @param {Object} [extraValues] Any additional state to be assigned
11 | * @returns {Object}
12 | */
13 | export const updateStore = (state, action, extraValues = {}) => {
14 | const { type = '', payload = {}, meta = { status: '' } } = action;
15 | switch (meta.status) {
16 | case SUCCESS:
17 | return {
18 | ...state,
19 | ...extraValues,
20 | messages: { ...state.messages, [type]: _.get(payload, 'message') },
21 | loading: { ...state.loading, [type]: false },
22 | errors: { ...state.errors, [type]: [] },
23 | };
24 | case ERROR:
25 | return {
26 | ...state,
27 | messages: { ...state.messages, [type]: '' },
28 | loading: { ...state.loading, [type]: false },
29 | errors: { ...state.errors, [type]: _.get(payload, 'data.errors') || _.get(payload, 'errors') || action.payload || [] },
30 | };
31 | case PENDING:
32 | default:
33 | return {
34 | ...state,
35 | messages: { ...state.messages, [type]: '' },
36 | loading: { ...state.loading, [type]: true },
37 | errors: { ...state.errors, [type]: [] },
38 | };
39 | }
40 | };
41 |
42 | /**
43 | * buildGenericInitialState - Builds initial state for a set of constants
44 | * (loading, errors, messages)
45 | *
46 | * @param {Array} constants Array of constants to build state around
47 | * @returns {Object}
48 | */
49 | export const buildGenericInitialState = constants => ({
50 | messages: constants.reduce((retObj, constant) => {
51 | retObj[constant] = '';
52 | return retObj;
53 | }, {}),
54 | errors: constants.reduce((retObj, constant) => {
55 | retObj[constant] = [];
56 | return retObj;
57 | }, {}),
58 | loading: constants.reduce((retObj, constant) => {
59 | retObj[constant] = false;
60 | return retObj;
61 | }, {}),
62 | });
63 |
64 | /**
65 | * handleError - Dispatches error properly to Redux stores
66 | *
67 | * @param {Function} dispatch Redux dispatch function
68 | * @param {Object} error Error container
69 | * @param {String} type Action type constant for error received
70 | */
71 | export const handleError = (dispatch, error, type) => {
72 | const foundError = _.get(error, 'response.data.errors') || [{ error }];
73 | return dispatch({
74 | type,
75 | payload: foundError,
76 | meta: { status: ERROR },
77 | });
78 | };
79 |
80 | /**
81 | * removeMetaFromState - Remove metadata from state (general selector)
82 | *
83 | * @param {Object} state State to filter metadata out of
84 | */
85 | export const removeMetaFromState = state => Object.keys(state).reduce((accum, val) => {
86 | if (val !== 'errors' && val !== 'messages' && val !== 'loading') {
87 | accum[val] = state[val];
88 | }
89 |
90 | return accum;
91 | }, {});
92 |
--------------------------------------------------------------------------------
/app/src/components/header/header.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { Link } from 'react-router-dom';
5 | import { getAuthenticatedUser } from '../../redux/modules/user';
6 | import { logoutUser } from '../../redux/modules/authentication';
7 | import { mobileBreakpoint } from '../../constants/ui-constants';
8 | import { getComponentTranslator } from '../../util/i18n';
9 |
10 | const translate = getComponentTranslator('header');
11 |
12 | class Header extends Component {
13 | state = {
14 | isMobile: window.innerWidth <= mobileBreakpoint,
15 | mobileNavOpen: false,
16 | };
17 |
18 | componentWillMount = () => {
19 | window.addEventListener('resize', this.mobileCheck);
20 | }
21 |
22 | componentWillUnmount = () => {
23 | window.removeEventListener('resize', this.mobileCheck);
24 | }
25 |
26 | mobileCheck = () => this.setState({ isMobile: window.innerWidth <= mobileBreakpoint });
27 |
28 | buildNavigation = () => {
29 | const { user } = this.props;
30 | const links = [
31 | {
32 | name: translate('dashboard'),
33 | link: 'dashboard',
34 | authenticated: true,
35 | },
36 | {
37 | name: (user && user.firstName) || translate('profile'),
38 | link: 'profile',
39 | authenticated: true,
40 | },
41 | {
42 | name: translate('logout'),
43 | onClick: this.props.logoutUser,
44 | authenticated: true,
45 | },
46 | {
47 | name: translate('login'),
48 | link: 'login',
49 | authenticated: false,
50 | },
51 | {
52 | name: translate('register'),
53 | link: 'register',
54 | authenticated: false,
55 | },
56 | ];
57 |
58 | return (
59 |
60 | {links.filter(link => link.authenticated === this.props.authenticated).map(link => (
61 | -
62 | {link.link && {link.name}}
63 | {link.onClick && {link.name}}
64 |
65 | ))}
66 |
67 | );
68 | };
69 |
70 | toggleMobileNav = () => this.setState({ mobileNavOpen: !this.state.mobileNavOpen });
71 |
72 | render() {
73 | const { isMobile, mobileNavOpen } = this.state;
74 |
75 | return (
76 |
93 | );
94 | }
95 | }
96 |
97 | Header.propTypes = {
98 | user: PropTypes.shape({
99 | firstName: PropTypes.string,
100 | }),
101 | authenticated: PropTypes.bool,
102 | logoutUser: PropTypes.func,
103 | };
104 |
105 | const mapStateToProps = ({ user, authentication }) => ({
106 | user: getAuthenticatedUser({ user, authentication }),
107 | authenticated: authentication.authenticated,
108 | });
109 |
110 | export default connect(mapStateToProps, { logoutUser })(Header);
111 |
--------------------------------------------------------------------------------
/app/src/util/http-utils.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { getApiUrl, getEnvironment } from './environment-utils';
3 | import { getCookie } from './cookie-utils';
4 | import { PENDING, SUCCESS, POST, PUT, GET, DELETE } from './redux-constants';
5 |
6 | const API_URL = getApiUrl();
7 |
8 | /**
9 | * logError - Log error without UI display
10 | * @param {Object} error Error object caught in catch block
11 | * @param {String} type Action type that caused error
12 | *
13 | * @returns {Promise}
14 | */
15 | export const logError = (error, type) => {
16 | if (getEnvironment() === 'development') {
17 | console.error(`Error type: ${type}.`);
18 | console.error(error);
19 | }
20 |
21 | const errorMessage = error && error.response
22 | ? error.response.data
23 | : error;
24 |
25 | return Promise.reject(errorMessage);
26 | };
27 |
28 | /**
29 | * httpRequest - Generic action to make an http request with axios
30 | * @param {Function} dispatch React-redux's dispatch function
31 | * @param {String} requestType Type of http request to make
32 | * @param {String} actionType Action type to be dispatched
33 | * @param {Object} opts Object of options
34 | * endpoint Api endpoint to hit (e.g., '/auth/login')
35 | * data Data to be posted to the api
36 | * requiresAuth Whether or not request needs to be authenticated
37 | *
38 | * @returns {Promise}
39 | */
40 | const httpRequest = async (dispatch, requestType = GET, actionType = '', opts = {}) => {
41 | try {
42 | dispatch({
43 | type: actionType,
44 | meta: { status: PENDING },
45 | });
46 |
47 | const reqArgs = [`${API_URL}/${opts.endpoint || ''}`];
48 |
49 | // Add a data payload to the request if it's a POST or PUT
50 | if (requestType === POST || requestType === PUT) {
51 | reqArgs.push(opts.data || {});
52 | }
53 |
54 | // Add Authorization header if the request needs to be authenticated with
55 | // a JSON Web Token, else add an empty object
56 | reqArgs.push(
57 | opts.requiresAuth
58 | ? { headers: { Authorization: getCookie('token') } }
59 | : {},
60 | );
61 |
62 | const response = await axios[requestType](...reqArgs);
63 |
64 | dispatch({
65 | type: actionType,
66 | meta: { status: SUCCESS },
67 | payload: response.data,
68 | });
69 |
70 | return Promise.resolve(response.data);
71 | } catch (err) {
72 | throw err;
73 | }
74 | };
75 |
76 | /**
77 | * post - Generic action to make a POST request with axios
78 | * @param {Function} dispatch React-redux's dispatch function
79 | * @param {String} type Action type to be dispatched
80 | * @param {String} endpoint Api endpoint to hit (e.g., '/auth/login')
81 | * @param {Object} data Data to be posted to the api
82 | * @param {Boolean} requiresAuth Whether or not request needs to be authenticated
83 | *
84 | * @returns {Promise}
85 | */
86 | export const post = (dispatch, type, endpoint, data, requiresAuth) =>
87 | httpRequest(dispatch, POST, type, { endpoint, data, requiresAuth });
88 |
89 | /**
90 | * put - Generic action to make a PUT request with axios
91 | * @param {Function} dispatch React-redux's dispatch function
92 | * @param {String} type Action type to be dispatched
93 | * @param {String} endpoint Api endpoint to hit (e.g., '/user/:id')
94 | * @param {Object} data Data to be posted to the api
95 | * @param {Boolean} requiresAuth Whether or not request needs to be authenticated
96 | *
97 | * @returns {Promise}
98 | */
99 | export const put = async (dispatch, type, endpoint, data, requiresAuth) =>
100 | httpRequest(dispatch, PUT, type, { endpoint, data, requiresAuth });
101 |
102 | /**
103 | * get - Generic action to make a GET request with axios
104 | * @param {Function} dispatch React-redux's dispatch function
105 | * @param {String} type Action type to be dispatched
106 | * @param {String} endpoint Api endpoint to hit (e.g., '/user')
107 | * @param {Boolean} requiresAuth Whether or not request needs to be authenticated
108 | *
109 | * @returns {Promise}
110 | */
111 | export const get = async (dispatch, type, endpoint, requiresAuth) =>
112 | httpRequest(dispatch, GET, type, { endpoint, requiresAuth });
113 |
114 | /**
115 | * del - Generic action to make a DELETE request with axios
116 | * @param {Function} dispatch React-redux's dispatch function
117 | * @param {String} type Action type to be dispatched
118 | * @param {String} endpoint Api endpoint to hit (e.g., '/user/:id')
119 | * @param {Boolean} requiresAuth Whether or not request needs to be authenticated
120 | *
121 | * @returns {Promise}
122 | */
123 | export const del = async (dispatch, type, endpoint, requiresAuth) =>
124 | httpRequest(dispatch, DELETE, type, { endpoint, requiresAuth });
125 |
--------------------------------------------------------------------------------
/app/src/redux/modules/authentication.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { APP_NAMESPACE } from '../../util/redux-constants';
3 | import { get, post } from '../../util/http-utils';
4 | import { deleteCookie, getCookie, setCookie } from '../../util/cookie-utils';
5 | import { updateStore, buildGenericInitialState, handleError } from '../../util/store-utils';
6 | import { getAppUrl } from '../../util/environment-utils';
7 |
8 | const AUTH_ENDPOINT_BASE = 'auth';
9 | const typeBase = `${APP_NAMESPACE}/${AUTH_ENDPOINT_BASE}/`;
10 |
11 | // Constants
12 | export const CHANGE_AUTH = `${typeBase}CHANGE_AUTH`;
13 | export const SET_POST_AUTH_PATH = `${typeBase}SET_POST_AUTH_PATH`;
14 | export const RESET_PASSWORD = `${typeBase}RESET_PASSWORD`;
15 | export const GET_AUTHENTICATED_USER = `${typeBase}GET_AUTHENTICATED_USER`;
16 |
17 | // Actions
18 | export const changeAuthentication = payload => dispatch =>
19 | dispatch({
20 | type: CHANGE_AUTH,
21 | payload,
22 | });
23 |
24 | /**
25 | * login - Authenticate a user with an email and password
26 | * @param {Object} credentials Login credentials (email, password)
27 | */
28 | export const login = (credentials, desiredPath) => async (dispatch) => {
29 | try {
30 | const response = await post(dispatch, CHANGE_AUTH, `${AUTH_ENDPOINT_BASE}/login`, credentials, false);
31 |
32 | // If the login was successful, set the JWT as a cookie
33 | if (response) {
34 | setCookie('token', response.token, { maxAge: response.tokenExpiration });
35 |
36 | if (desiredPath) {
37 | window.location.href = `${getAppUrl()}${desiredPath}`;
38 | } else {
39 | window.location.href = `${getAppUrl()}/dashboard`;
40 | }
41 | }
42 | } catch (err) {
43 | await handleError(dispatch, err, CHANGE_AUTH);
44 | }
45 | };
46 |
47 | /**
48 | * register - Creates a new account for a user
49 | * @param {Object} formData User's form data
50 | */
51 | export const register = formData => async (dispatch) => {
52 | try {
53 | const response = await post(dispatch, CHANGE_AUTH, `${AUTH_ENDPOINT_BASE}/register`, formData, false);
54 |
55 | // If the registration was successful, set the JWT as a cookie
56 | if (response) {
57 | setCookie('token', response.token, { maxAge: response.tokenExpiration });
58 | window.location.href = `${getAppUrl()}/dashboard`;
59 | }
60 | } catch (err) {
61 | await handleError(dispatch, err, CHANGE_AUTH);
62 | }
63 | };
64 |
65 | /**
66 | * setPostAuthPath - Save Desired Pre-Auth Path to State
67 | * @param {String} payload The desired path, saved pre-authentication
68 | * @returns {function}
69 | */
70 | export const setPostAuthPath = payload => dispatch =>
71 | dispatch({
72 | type: SET_POST_AUTH_PATH,
73 | payload,
74 | });
75 |
76 | /**
77 | * logoutUser - Log user out by clearing auth state and token cookie
78 | */
79 | export const logoutUser = () => (dispatch) => {
80 | dispatch({ type: CHANGE_AUTH, payload: {} });
81 | deleteCookie('token');
82 |
83 | window.location.href = `${getAppUrl()}/login`;
84 | };
85 |
86 | /**
87 | * forgotPassword - Sends user an email with a token to reset their password
88 | * @param {Object} formData The user's email address
89 | * @returns {Promise}
90 | */
91 | export const forgotPassword = formData => async (dispatch) => {
92 | try {
93 | const response = await post(dispatch, CHANGE_AUTH, `${AUTH_ENDPOINT_BASE}/forgot-password`, formData, false);
94 | return Promise.resolve(response);
95 | } catch (err) {
96 | await handleError(dispatch, err, CHANGE_AUTH);
97 | }
98 | };
99 |
100 | /**
101 | * resetPassword - Resets a user's password, given a valid token
102 | * @param {Object} formData The user's email address
103 | * @param {String} token Valid token required for password reset
104 | * @returns {Promise}
105 | */
106 | export const resetPassword = (formData, token) => async (dispatch) => {
107 | try {
108 | const response = await post(dispatch, CHANGE_AUTH, `${AUTH_ENDPOINT_BASE}/reset-password/${token}`, formData, false);
109 | return Promise.resolve(response);
110 | } catch (err) {
111 | await handleError(dispatch, err, CHANGE_AUTH);
112 | }
113 | };
114 |
115 | /**
116 | * getAuthenticatedUser - Retrieves the logged in user's information
117 | * @returns {Promise}
118 | */
119 | export const getAuthenticatedUser = () => async (dispatch) => {
120 | try {
121 | const response = await get(dispatch, GET_AUTHENTICATED_USER, `${AUTH_ENDPOINT_BASE}/profile`, true);
122 | return Promise.resolve(response);
123 | } catch (err) {
124 | await handleError(dispatch, err, GET_AUTHENTICATED_USER);
125 | }
126 | };
127 |
128 | // Store
129 | const INITIAL_STATE = {
130 | authenticated: Boolean(getCookie('token')),
131 | user: '',
132 | ...buildGenericInitialState([CHANGE_AUTH, SET_POST_AUTH_PATH, RESET_PASSWORD, GET_AUTHENTICATED_USER]),
133 | };
134 |
135 | export default (state = INITIAL_STATE, action) => {
136 | switch (action.type) {
137 | case CHANGE_AUTH:
138 | return updateStore(state, action, { authenticated: Boolean(_.get(action, 'payload.token')), user: _.get(action, 'payload.user.id') });
139 | case GET_AUTHENTICATED_USER:
140 | return updateStore(state, action, { user: _.get(action, 'payload.user.id') });
141 | default:
142 | return state;
143 | }
144 | };
145 |
--------------------------------------------------------------------------------
/api/controllers/billing.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash');
2 | const config = require('../config');
3 | const stripe = require('stripe')(config.billing.stripeApiKey);
4 | const moment = require('moment');
5 | const User = require('../models/user');
6 | const sendEmail = require('../utils/email-utils').sendEmail;
7 |
8 | /**
9 | * stripeWebhook - Handles webhooks sent from Stripe
10 | */
11 | exports.stripeWebhook = async (ctx, next) => {
12 | if (!_.get(ctx, 'request.body.id')) {
13 | ctx.status = 200;
14 | await next();
15 | }
16 |
17 | try {
18 | // Request to expand the webhook for added security
19 | const verifiedEvent = await stripe.events.retrieve(ctx.request.body.id);
20 |
21 | // Check for Stripe test webhook event
22 | if (verifiedEvent.id === 'evt_00000000000000') {
23 | console.log('Webhook test succeeded!');
24 | await next();
25 | }
26 |
27 | // Respond to different webhook events, depending on what they are for
28 | switch (verifiedEvent.type) {
29 | // On a successful invoice payment, add another billing term to the
30 | // corresponding user's next_payment_due field
31 | case 'invoice.payment_succeeded': {
32 | const subscriptionId = _.get(verifiedEvent, 'data.object.subscription');
33 | const customerId = _.get(verifiedEvent, 'data.object.customer');
34 | // Convert UNIX to Postgres usable date, fallback to one month from now
35 | const paymentDueOn = _.get(verifiedEvent, 'data.object.lines.data')
36 | ? moment(_.get(verifiedEvent, 'data.object.lines.data')[0].period.end, 'X')
37 | : moment().add(1, 'month');
38 |
39 | const user = await User.update(
40 | { 'billing.subscriptionId': subscriptionId },
41 | { nextPaymentDue: paymentDueOn },
42 | );
43 |
44 | if (!user) {
45 | ctx.throw(500, `Successful payment could not be credited for customerId: ${customerId}`);
46 | }
47 |
48 | // Next payment due date was successfully updated
49 | console.log(`Payment for ${user.email} was successful. Subscription good until ${paymentDueOn}'`);
50 | ctx.status = 200;
51 | await next();
52 | break;
53 | }
54 | // If the user's payment fails, email them to let them know
55 | case 'invoice.payment_failed': {
56 | const customerId = _.get(verifiedEvent, 'data.object.customer');
57 | const user = await User.findOne({ 'billing.customerId': customerId });
58 |
59 | if (!user) {
60 | ctx.throw(500, `Failed invoice payment reminder could not be sent for customerId: ${customerId}`);
61 | }
62 |
63 | const message = {
64 | subject: 'Payment failed',
65 | text: `You are receiving this message because your most recent payment for $${_.get(verifiedEvent, 'data.object.amount_due') / 100} failed.
66 | This could be due to a change or expiration on your provided credit card or interference from your bank.
67 | Please update your payment information as soon as possible by logging in. Thank you.`,
68 | };
69 |
70 | await sendEmail(user.email, message);
71 | ctx.status = 200;
72 | break;
73 | }
74 | default:
75 | ctx.status = 200;
76 | break;
77 | }
78 | } catch (err) {
79 | ctx.throw(500, err);
80 | }
81 | };
82 |
83 | /**
84 | * createCustomer - If user doesn't have associated Stripe customer, create one,
85 | * else fetch and return the existing customer object
86 | */
87 | exports.createCustomer = async (ctx) => {
88 | try {
89 | let customer;
90 |
91 | if (_.get(ctx, 'state.user.billing.customerId')) {
92 | customer = await stripe.customers.retrieve(ctx.state.user.billing.customerId);
93 | } else {
94 | customer = await stripe.customers.create({
95 | source: ctx.state.token,
96 | email: ctx.state.customerEmail,
97 | });
98 | }
99 | ctx.state.customer = customer;
100 | } catch (err) {
101 | ctx.throw(500, err);
102 | }
103 | };
104 |
105 | /**
106 | * createSubscription - Creates a subscription for a user
107 | */
108 | exports.createSubscription = async (ctx, next) => {
109 | const { stripeToken, plan, isTrial = true, quantity = 1 } = ctx.request.body;
110 |
111 | try {
112 | const user = await User.findById(ctx.state.user.id || null);
113 |
114 | // Create customer or fetch customer with Stripe
115 | ctx.state.token = stripeToken;
116 | ctx.state.customerEmail = user.email;
117 |
118 | // Move to the createCustomer middleware (in case a customer isn't associated yet)
119 | await next();
120 |
121 | if (user && !_.get(user, 'billing.customerId')) {
122 | // After createCustomer middleware, add the customer id to the user
123 | _.set(user, 'billing.customerId', ctx.state.customer.id);
124 | }
125 |
126 | // Next, create the subscription with a 30-day free trial
127 | const stripeSubscription = await stripe.subscriptions.create({
128 | plan,
129 | quantity,
130 | customer: user.billing.customerId,
131 | trial_period_days: isTrial ? 30 : 0,
132 | });
133 |
134 | _.set(user, 'billing.subscriptionId', stripeSubscription.id);
135 | _.set(user, 'billing.plan', plan);
136 | _.set(user, 'billing.nextPaymentDue', moment().add(1, 'month'));
137 |
138 | // Save the updated user
139 | await user.save();
140 |
141 | ctx.status = 200;
142 | ctx.body = {
143 | message: `Your subscription to the ${plan} plan has been started.`,
144 | customer: ctx.state.customer,
145 | };
146 | } catch (err) {
147 | ctx.throw(500, err);
148 | }
149 | };
150 |
151 | exports.deleteSubscription = async (ctx) => {
152 | try {
153 | // Look up the user requesting a subscription change
154 | const user = await User.findById(ctx.state.user.id);
155 |
156 | await stripe.subscriptions.del(user.billing.subscriptionId, {
157 | at_period_end: true,
158 | });
159 |
160 | user.billing.subscriptionId = undefined;
161 | user.billing.plan = undefined;
162 |
163 | await user.save();
164 | ctx.status = 200;
165 | ctx.body = { message: 'Subscription successfully deleted.' };
166 | } catch (err) {
167 | ctx.throw(500, err);
168 | }
169 | };
170 |
171 |
172 | /**
173 | * getCustomer - Gets a customer's data from Stripe
174 | */
175 | exports.getCustomer = async (ctx) => {
176 | try {
177 | const user = await User.findById(ctx.state.user.id);
178 | const customer = await stripe.customers.retrieve(user.billing.customerId);
179 |
180 | ctx.status = 200;
181 | ctx.body = { customer };
182 | } catch (err) {
183 | ctx.throw(500, err);
184 | }
185 | };
186 |
--------------------------------------------------------------------------------
/api/controllers/auth.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto-promise');
2 | const moment = require('moment');
3 | const passport = require('../config').passport;
4 | const User = require('../models/user');
5 | const userUtils = require('../utils/user-utils');
6 | const emailUtils = require('../utils/email-utils');
7 | const validationUtils = require('../utils/validation-utils');
8 | const ERRORS = require('../constants').ERRORS;
9 |
10 | const { standardizeUser, generateJWT, getRole } = userUtils;
11 | const { sendEmail } = emailUtils;
12 | const { responseValidator } = validationUtils;
13 |
14 | /**
15 | * createTokenCtx - Creates JWT info for ctx.body
16 | * @param {Object} user User object to convert to generate JWT with
17 | */
18 | const createTokenCtx = (user) => {
19 | const tokenData = generateJWT(user);
20 |
21 | return {
22 | token: `JWT ${tokenData.token}`,
23 | tokenExpiration: tokenData.expiration,
24 | user: standardizeUser(user),
25 | };
26 | };
27 |
28 |
29 | /**
30 | * jwtAuth - Attempts to authenticate a user via a JWT in the Authorization
31 | * header.
32 | */
33 | exports.jwtAuth = (ctx, next) => passport.authenticate('jwt', async (err, payload) => {
34 | const epochTimestamp = Math.round((new Date()).getTime() / 1000);
35 |
36 | // If there is no payload, inform the user they are not authorized to see the content
37 | if (!payload) {
38 | ctx.status = 401;
39 | ctx.body = { errors: { error: ERRORS.JWT_FAILURE }, jwtExpired: true };
40 | // Check if JWT has expired, return error if so
41 | } else if (payload.exp <= epochTimestamp) {
42 | ctx.status = 401;
43 | ctx.body = { errors: { error: ERRORS.JWT_EXPIRED }, jwtExpired: true };
44 | } else {
45 | // Add user to state
46 | ctx.state.user = payload;
47 | await next();
48 | }
49 | })(ctx, next);
50 |
51 | /**
52 | * localAuth - Attempts to login a user with an email address and password
53 | * using PassportJS (http://passportjs.org/docs)
54 | */
55 | exports.login = (ctx, next) => passport.authenticate('local', async (err, user) => {
56 | if (!user || !Object.keys(user).length) {
57 | ctx.status = 401;
58 | ctx.body = { errors: [{ error: ERRORS.BAD_LOGIN }] };
59 | await next();
60 | } else {
61 | ctx.body = Object.assign(ctx.body || {}, createTokenCtx(user));
62 | await next();
63 | }
64 | })(ctx, next);
65 |
66 |
67 | /**
68 | * register - Attempts to register a new user, if a user with that email
69 | * address does not already exist.
70 | */
71 | exports.register = async (ctx, next) => {
72 | // Check for registration errors
73 | const validation = responseValidator(ctx.request.body, [
74 | { name: 'email', required: true },
75 | { name: 'name', required: true },
76 | { name: 'password', required: true },
77 | ]);
78 |
79 | if (validation && validation.length && validation[0].error) {
80 | ctx.status = 422;
81 | ctx.body = { errors: validation };
82 | await next();
83 | }
84 |
85 | const { email, password, name } = validation;
86 |
87 | if (email && password && name) {
88 | const formattedEmail = email.toLowerCase();
89 | try {
90 | let user = await User.findOne({ email: formattedEmail });
91 |
92 | if (user !== null) {
93 | ctx.status = 422;
94 | ctx.body = { errors: [{ error: ERRORS.ALREADY_REGISTERED }] };
95 | await next();
96 | } else {
97 | user = new User({
98 | name,
99 | password,
100 | email,
101 | });
102 |
103 | const savedUser = await user.save();
104 | ctx.body = Object.assign(ctx.body || {}, createTokenCtx(savedUser));
105 | await next();
106 | }
107 | } catch (err) {
108 | ctx.throw(500, err);
109 | }
110 | }
111 | };
112 |
113 |
114 | /**
115 | * forgotPassword - Allows a user to request a password reset, but does not
116 | * actually reset a password. Sends link in email for security.
117 | */
118 | exports.forgotPassword = async (ctx, next) => {
119 | const { email } = ctx.request.body;
120 | try {
121 | const buffer = await crypto.randomBytes(48);
122 | const resetToken = buffer.toString('hex');
123 | const user = await User.findOneAndUpdate({ email },
124 | {
125 | resetPasswordToken: resetToken,
126 | resetPasswordExpires: moment().add(1, 'hour'),
127 | });
128 |
129 | // If a user was actually updated, send an email
130 | if (user) {
131 | const message = {
132 | subect: 'Reset Password',
133 | text: `${'You are receiving this because you (or someone else) have requested the reset of the password for your account.\n\n' +
134 | 'Please click on the following link, or paste this into your browser to complete the process:\n\n' +
135 | 'http://'}${ctx.host}/reset-password/${resetToken}\n\n` +
136 | 'If you did not request this, please ignore this email and your password will remain unchanged.\n',
137 | };
138 |
139 | await sendEmail(email, message);
140 | }
141 |
142 | ctx.body = {
143 | message: `We sent an email to ${email} containing a password reset link. It will expire in one hour.`,
144 | };
145 |
146 | await next();
147 | } catch (err) {
148 | ctx.throw(500, err);
149 | }
150 | };
151 |
152 |
153 | /**
154 | * resetPassword - Allows user with token from email to reset their password
155 | */
156 | exports.resetPassword = async (ctx, next) => {
157 | const { password, confirmPassword } = ctx.request.body;
158 | const { resetToken } = ctx.params;
159 |
160 | try {
161 | if (password && confirmPassword && password !== confirmPassword) {
162 | ctx.status = 422;
163 | ctx.body = { errors: [{ error: ERRORS.PASSWORD_CONFIRM_FAIL }] };
164 | } else {
165 | const user = await User.findOneAndUpdate(
166 | { resetPasswordToken: resetToken, resetPasswordExpires: { $gt: Date.now() } },
167 | { password, resetPasswordToken: undefined, resetPasswordExpires: undefined });
168 |
169 | if (!user) {
170 | // If no user was found, their reset request likely expired. Tell them that.
171 | ctx.status = 422;
172 | ctx.body = { errors: [{ error: ERRORS.PASSWORD_RESET_EXPIRED }] };
173 | } else {
174 | // If the user reset their password successfully, let them know
175 | ctx.body = { message: 'Your password has been successfully updated. Please login with your new password.' };
176 | }
177 | await next();
178 | }
179 | } catch (err) {
180 | ctx.throw(500, err);
181 | }
182 | };
183 |
184 | /**
185 | * requireRole - Ensures a user has a high enough role to access an endpoint
186 | */
187 | exports.requireRole = async role =>
188 | async (ctx, next) => {
189 | const { user } = ctx.state.user;
190 | try {
191 | const foundUser = await User.findById(user.id);
192 | // If the user couldn't be found, return an error
193 | if (!foundUser) {
194 | ctx.status = 404;
195 | ctx.body = { errors: [{ error: ERRORS.USER_NOT_FOUND }] };
196 | } else {
197 | // Otherwise, continue checking role
198 | if (getRole(user.role) >= getRole(role)) {
199 | await next();
200 | }
201 |
202 | ctx.status = 403;
203 | ctx.body = { errors: [{ error: ERRORS.NO_PERMISSION }] };
204 | }
205 | } catch (err) {
206 | ctx.throw(500, err);
207 | }
208 | };
209 |
210 | /**
211 | * getAuthenticatedUser - Returns JSON for the authenticated user
212 | */
213 | exports.getAuthenticatedUser = async (ctx, next) => {
214 | const user = await User.findById(ctx.state.user.id);
215 | ctx.status = 200;
216 | ctx.body = { user: standardizeUser(user) };
217 | await next();
218 | };
219 |
--------------------------------------------------------------------------------
/app/src/assets/stylesheets/partials/_forms.scss:
--------------------------------------------------------------------------------
1 | .form {
2 | border-radius: 2px;
3 | box-sizing: border-box;
4 |
5 | &-list {
6 | list-style-type: none;
7 | margin: 0 0 ($unit * 5) 0;
8 | padding: 0;
9 |
10 | li {
11 | margin-bottom: $unit * 3;
12 |
13 | ul {
14 |
15 | li {
16 | list-style-type: none;
17 |
18 | .form-label {
19 | font-size: 12px;
20 | }
21 | }
22 | }
23 |
24 | &.is-half {
25 | width: 45%;
26 | }
27 |
28 | &:last-child {
29 | margin-bottom: 0;
30 | }
31 | }
32 | }
33 |
34 | &-label {
35 | display: block;
36 | font-size: 14px;
37 | line-height: $unit * 2;
38 | margin-bottom: $unit;
39 | text-transform: uppercase;
40 | font-weight: bold;
41 |
42 | &.has-error {
43 | color: $error-color;
44 | }
45 | }
46 |
47 | &-hint {
48 | color: $gray-border;
49 | font-size: 12px;
50 | line-height: $unit * 2;
51 | margin-bottom: $unit;
52 | }
53 |
54 | &-control, .StripeElement {
55 | background: rgba(#FFF, .56);
56 | border: 1px solid $gray-border;
57 | border-radius: 2px;
58 | box-sizing: border-box;
59 | display: block;
60 | font-size: 20px;
61 | font-weight: 300;
62 | height: 40px;
63 | outline: none;
64 | padding: 0 ($unit * 2);
65 | width: 100%;
66 | transition: border-color .3s ease-in-out, background .3s ease-in-out, box-shadow .3s ease-in-out;
67 |
68 | select {
69 | &::after {
70 | border: 1px solid $primary-color;
71 | border-right: 0;
72 | border-top: 0;
73 | content: " ";
74 | display: block;
75 | height: 0.5em;
76 | pointer-events: none;
77 | position: absolute;
78 | -webkit-transform: rotate(-45deg);
79 | transform: rotate(-45deg);
80 | width: 0.5em;
81 | margin-top: -0.375em;
82 | right: 1.125em;
83 | top: 50%;
84 | z-index: 4;
85 | }
86 | }
87 |
88 | // PLACEHOLDER TEXT
89 | &::-webkit-input-placeholder { /* Chrome/Opera/Safari */
90 | color: $gray-border;
91 | }
92 | &::-moz-placeholder { /* Firefox 19+ */
93 | color: $gray-border;
94 | }
95 | &:-ms-input-placeholder { /* IE 10+ */
96 | color: $gray-border;
97 | }
98 | &:-moz-placeholder { /* Firefox 18- */
99 | color: $gray-border;
100 | }
101 |
102 | // AUTO FILL OVERRIDE
103 | &:-webkit-autofill,
104 | &:-webkit-autofill:hover,
105 | &:-webkit-autofill:focus {
106 | background-color: #FFF;
107 | transition: background-color 5000s ease-in-out 0s;
108 | }
109 |
110 | &:hover,
111 | &:active,
112 | &:focus {
113 | border-color: $focus-color;
114 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, .24);
115 | }
116 |
117 | @at-root textarea#{&} {
118 | height: auto;
119 | padding: 10px;
120 | }
121 |
122 | @at-root input[type="checkbox"]#{&} {
123 | width: auto;
124 | height: auto;
125 |
126 | &:hover {
127 | box-shadow: none;
128 | }
129 | }
130 |
131 | &::-ms-expand {
132 | border: 0;
133 | background-color: transparent;
134 | }
135 | }
136 | }
137 |
138 | input[type="radio"],
139 | input[type="checkbox"] {
140 | margin: 4px 0 0;
141 | margin-top: 1px \9;
142 | line-height: normal;
143 | }
144 |
145 | // LIVE SEARCH
146 | .rw-widget {
147 | background-color: inherit !important;
148 | border: none !important;
149 | border-radius: 0 !important;
150 | }
151 |
152 | .rw-input {
153 | @extend .form-control;
154 | }
155 | .live-search-result {
156 | box-sizing: border-box;
157 | cursor: pointer;
158 | display: block;
159 | height: $unit * 8;
160 | padding: ($unit * 2) ($unit * 2) ($unit * 2) ($unit * 2);
161 | position: relative;
162 |
163 | // DIVIDER
164 | &:after {
165 | background-image: linear-gradient(-90deg, #FFF 0%, $gray-border 51%, #FFF 100%);
166 | bottom: 0;
167 | content: ' ';
168 | display: block;
169 | height: 1px;
170 | left: $unit * 2;
171 | position: absolute;
172 | width: 344px;
173 |
174 | &:last-child {
175 | display: none;
176 | }
177 | }
178 |
179 | img {
180 | border-radius: 1000px;
181 | height: $unit * 4;
182 | left: $unit * 2;
183 | position: absolute;
184 | top: $unit * 2;
185 | width: $unit * 4;
186 | z-index: 10;
187 | }
188 |
189 | h4 {
190 | font-size: 14px;
191 | font-weight: 400;
192 | line-height: 16px;
193 | margin: 0;
194 | }
195 |
196 | p {
197 | color: $gray-border;
198 | font-size: 12px;
199 | font-weight: 300;
200 | margin: 0;
201 | }
202 | }
203 |
204 | .rw-multiselect {
205 | position: relative;
206 | z-index: 20;
207 |
208 | .rw-multiselect-taglist {
209 | margin: 7px;
210 | list-style-type: none;
211 | padding: 0;
212 | position: absolute;
213 |
214 | li {
215 | background: #FFF;
216 | border-radius: 2px;
217 | box-shadow: 0 1px 2px rgba(0, 0, 0, .24);
218 | display: inline-block;
219 | font-size: 16px;
220 | line-height: $unit * 3;
221 | margin: 0;
222 | padding: 0 ($unit * 3) 0 $unit;
223 | }
224 |
225 | .rw-tag-btn {
226 | background: #FFF;
227 | border-radius: 200px;
228 | color: $gray-border;
229 | cursor: pointer;
230 | display: block;
231 | font-size: 18px;
232 | line-height: $unit * 2;
233 | position: absolute;
234 | height: $unit * 2;
235 | right: 4px;
236 | text-align: center;
237 | top: 4px;
238 | width: $unit * 2;
239 | }
240 | }
241 |
242 | // NOTIFICATION MESSAGE
243 | .rw-sr {
244 | display: none;
245 | }
246 |
247 | // INPUT FIELD
248 | .rw-popup-container {
249 | position: absolute;
250 | top: -8px;
251 | }
252 |
253 | .rw-input {
254 | @extend .form-control;
255 | display: block;
256 | color: transparent;
257 | }
258 |
259 | .rw-list {
260 | list-style-type: none;
261 | margin: 0;
262 | padding: 0;
263 |
264 | li {
265 | margin: 0;
266 | padding: 0;
267 | }
268 |
269 | .rw-list-empty {
270 | color: $gray-border;
271 | font-size: 18px;
272 | font-weight: 300;
273 | height: $unit * 12;
274 | line-height: $unit * 12;
275 | text-align: center;
276 | }
277 | }
278 |
279 | .rw-popup {
280 | background: #FFF;
281 | box-shadow: 0 0 16px rgba(0, 0, 0, .12), 0 16px 16px rgba(0, 0, 0, .24);
282 | left: 0;
283 | position: absolute;
284 | top: $unit * 6;
285 | width: 376px;
286 | z-index: 10;
287 | }
288 | }
289 |
290 | .rw-combobox {
291 | padding-right: inherit !important;
292 |
293 | &:hover {
294 | background-color: inherit !important;
295 | border-color: inherit !important;
296 | }
297 |
298 | input.rw-input {
299 | box-shadow: none !important;
300 | @extend .form-control;
301 | }
302 |
303 | ul.rw-list {
304 | background: #FFF;
305 |
306 | >li.rw-list-option {
307 | margin: 0;
308 | background: #FFF;
309 |
310 | &:hover {
311 | background-color: inherit !important;
312 | border-color: inherit !important;
313 | }
314 |
315 | &.rw-state-focus {
316 | border: none;
317 | }
318 | }
319 | }
320 |
321 | .rw-select.rw-btn {
322 | height: 100%;
323 | vertical-align: middle;
324 | outline: 0;
325 | border-left: 1px solid #ccc;
326 | color: #333;
327 | }
328 | }
329 |
330 | input[type=checkbox].branded-checkbox {
331 | margin: 0;
332 | position: absolute;
333 | opacity: 0;
334 | border-radius: 100%;
335 | height: 18px;
336 | width: 18px;
337 |
338 | & + label {
339 | position: relative;
340 | cursor: pointer;
341 | padding: 0;
342 |
343 | &:before {
344 | content: '';
345 | margin-right: 10px;
346 | display: inline-block;
347 | width: 14px;
348 | height: 14px;
349 | vertical-align: text-top;
350 | background: #FFF;
351 | border-radius: 100%;
352 | border: 2px solid $primary-color;
353 | }
354 |
355 | &.no-text {
356 | &:before {
357 | margin-right: 0;
358 | }
359 | }
360 | }
361 |
362 | &:checked + label:before {
363 | background: $primary-color;
364 | -webkit-box-shadow: inset 0px 0px 0px 2px rgba(255,255,255,1);
365 | -moz-box-shadow: inset 0px 0px 0px 2px rgba(255,255,255,1);
366 | box-shadow: inset 0px 0px 0px 2px rgba(255,255,255,1);
367 | }
368 | }
--------------------------------------------------------------------------------