├── Procfile ├── server ├── src │ ├── models │ │ ├── subscription │ │ │ ├── test.js │ │ │ └── index.js │ │ ├── index.js │ │ └── user │ │ │ ├── test.js │ │ │ └── index.js │ ├── setup-tests.js │ ├── graphql │ │ ├── user │ │ │ ├── resolvers │ │ │ │ ├── user │ │ │ │ │ └── index.js │ │ │ │ ├── query │ │ │ │ │ ├── index.js │ │ │ │ │ └── user.js │ │ │ │ ├── index.js │ │ │ │ └── mutation │ │ │ │ │ ├── index.js │ │ │ │ │ ├── signup.js │ │ │ │ │ ├── login │ │ │ │ │ └── index.js │ │ │ │ │ └── send-passcode.js │ │ │ ├── index.js │ │ │ └── type-defs.js │ │ ├── subscription │ │ │ ├── resolvers │ │ │ │ ├── query │ │ │ │ │ └── index.js │ │ │ │ ├── subscription │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ └── mutation │ │ │ │ │ ├── index.js │ │ │ │ │ ├── delete-subscription.js │ │ │ │ │ ├── save-subscription.js │ │ │ │ │ └── send-push-notification.js │ │ │ ├── index.js │ │ │ └── type-defs.js │ │ ├── base │ │ │ ├── type-defs.js │ │ │ ├── resolvers │ │ │ │ ├── index.js │ │ │ │ └── date.js │ │ │ └── index.js │ │ ├── all-schemas.js │ │ ├── exec-schema.js │ │ └── merge-schemas.js │ ├── startup │ │ ├── validation.js │ │ ├── apollo-server.js │ │ ├── db.js │ │ ├── logger.js │ │ └── env-vars.js │ ├── utils │ │ └── async-for-each.js │ ├── middlewares │ │ └── error.js │ ├── services │ │ ├── push │ │ │ ├── index.js │ │ │ ├── config.js │ │ │ └── send.js │ │ └── nodemailer │ │ │ └── config.js │ └── fixtures.js ├── jest.config.js ├── .gitignore ├── .eslintrc ├── package.json ├── .sample.env └── app.js ├── client ├── public │ ├── favicon.ico │ ├── img │ │ ├── mom.gif │ │ ├── demo-480.png │ │ ├── icons │ │ │ ├── icon-apple-72x72.png │ │ │ ├── icon-apple-76x76.png │ │ │ ├── icon-wp-144x144.png │ │ │ ├── icon-apple-114x114.png │ │ │ ├── icon-apple-120x120.png │ │ │ ├── icon-apple-144x144.png │ │ │ ├── icon-apple-152x152.png │ │ │ ├── android-chrome-72x72.png │ │ │ ├── icon-android-128x128.png │ │ │ ├── icon-android-144x144.png │ │ │ ├── icon-android-152x152.png │ │ │ ├── icon-android-192x192.png │ │ │ ├── icon-android-256x256.png │ │ │ └── icon-android-512x512.png │ │ ├── love-love-love-share.jpg │ │ ├── splash │ │ │ ├── splash-apple-320x460.png │ │ │ ├── splash-apple-640x920.png │ │ │ ├── splash-android-240x320.png │ │ │ ├── splash-apple-1024x748.png │ │ │ ├── splash-apple-1536x2008.png │ │ │ ├── splash-apple-2048x1496.png │ │ │ ├── splash-apple-640x1096.png │ │ │ ├── splash-apple-768x1004.png │ │ │ ├── splash-android-1080x1920.png │ │ │ └── splash-android-750x1334.png │ │ └── logo.svg │ ├── js │ │ ├── offline.js │ │ └── toast.js │ ├── manifest.json │ ├── manifest.sample.json │ ├── index.html │ ├── css │ │ └── style.css │ └── push-listener.js ├── .storybook │ ├── addons.js │ └── config.js ├── src │ ├── components │ │ ├── common │ │ │ ├── divider │ │ │ │ ├── stories.js │ │ │ │ ├── index.js │ │ │ │ └── test.js │ │ │ ├── loading │ │ │ │ ├── stories.js │ │ │ │ ├── test.js │ │ │ │ └── index.js │ │ │ ├── title │ │ │ │ ├── stories.js │ │ │ │ ├── index.js │ │ │ │ └── test.js │ │ │ ├── feedback │ │ │ │ ├── test.js │ │ │ │ ├── stories.js │ │ │ │ └── index.js │ │ │ ├── subtitle │ │ │ │ ├── stories.js │ │ │ │ ├── index.js │ │ │ │ └── test.js │ │ │ ├── alert │ │ │ │ ├── stories.js │ │ │ │ ├── test.js │ │ │ │ └── index.js │ │ │ └── button-link │ │ │ │ ├── stories.js │ │ │ │ ├── test.js │ │ │ │ └── index.js │ │ ├── route-wrappers │ │ │ ├── index.js │ │ │ ├── route-with-props.js │ │ │ ├── scroll-to-top.js │ │ │ └── logged-in-route.js │ │ ├── header-title │ │ │ ├── index.js │ │ │ └── test.js │ │ ├── auth │ │ │ ├── send-passcode.js │ │ │ ├── signup-api-call.js │ │ │ ├── login-api-call │ │ │ │ ├── index.js │ │ │ │ └── test.js │ │ │ ├── logout-btn.js │ │ │ ├── resend-passcode-btn.js │ │ │ ├── email-form │ │ │ │ ├── index.js │ │ │ │ └── test.js │ │ │ └── passcode-form │ │ │ │ ├── index.js │ │ │ │ └── test.js │ │ └── pwa │ │ │ ├── push-btn.js │ │ │ ├── unsubscribe-btn.js │ │ │ └── subscribe-btn.js │ ├── graphql │ │ ├── subscription │ │ │ ├── fragment │ │ │ │ ├── keys.js │ │ │ │ └── subscription.js │ │ │ └── mutation │ │ │ │ ├── send-push-notification.js │ │ │ │ ├── delete-subscription.js │ │ │ │ └── save-subscription.js │ │ ├── user │ │ │ ├── fragment │ │ │ │ └── user.js │ │ │ ├── query │ │ │ │ └── user.js │ │ │ └── mutation │ │ │ │ ├── send-passcode.js │ │ │ │ ├── signup.js │ │ │ │ └── login.js │ │ ├── mocks.js │ │ ├── apollo-mock-client.js │ │ └── apollo-client.js │ ├── storyshots.test.js │ ├── pages │ │ ├── not-found-page │ │ │ └── index.js │ │ ├── auth-page │ │ │ └── index.js │ │ ├── home-page │ │ │ └── index.js │ │ ├── login-page │ │ │ └── index.js │ │ └── signup-page │ │ │ └── index.js │ ├── theme │ │ ├── sc.js │ │ └── mui.js │ ├── render-props │ │ ├── index.js │ │ ├── disabled-props.js │ │ ├── service-props.js │ │ ├── message-props.js │ │ ├── form-props.js │ │ ├── hook-props.js │ │ └── pwa-btn-props.js │ ├── app │ │ ├── test.js │ │ └── index.js │ ├── index.js │ ├── routes.js │ ├── setupTests.js │ ├── layouts │ │ └── auth-page │ │ │ └── index.js │ ├── global-data-provider.js │ ├── __snapshots__ │ │ └── storyshots.test.js.snap │ └── register-sw.js ├── .gitignore ├── .eslintrc ├── sw-precache-config.js ├── .sample.env ├── package.json └── service-worker.tmpl ├── greenkeeper.json ├── .gitignore ├── .travis.yml └── package.json /Procfile: -------------------------------------------------------------------------------- 1 | web: yarn start:prod 2 | -------------------------------------------------------------------------------- /server/src/models/subscription/test.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/src/setup-tests.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/user/index.js: -------------------------------------------------------------------------------- 1 | const User = {}; 2 | 3 | module.exports = User; 4 | -------------------------------------------------------------------------------- /server/src/graphql/subscription/resolvers/query/index.js: -------------------------------------------------------------------------------- 1 | const Query = {}; 2 | 3 | module.exports = Query; 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/img/mom.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/mom.gif -------------------------------------------------------------------------------- /client/.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /server/src/graphql/base/type-defs.js: -------------------------------------------------------------------------------- 1 | const typeDefs = ` 2 | scalar Date 3 | `; 4 | 5 | module.exports = typeDefs; 6 | -------------------------------------------------------------------------------- /client/public/img/demo-480.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/demo-480.png -------------------------------------------------------------------------------- /server/src/graphql/subscription/resolvers/subscription/index.js: -------------------------------------------------------------------------------- 1 | const Subscription = {}; 2 | 3 | module.exports = Subscription; 4 | -------------------------------------------------------------------------------- /client/public/img/icons/icon-apple-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-apple-72x72.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-apple-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-apple-76x76.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-wp-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-wp-144x144.png -------------------------------------------------------------------------------- /client/public/img/love-love-love-share.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/love-love-love-share.jpg -------------------------------------------------------------------------------- /server/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | setupFiles: [ 4 | '/src/setup-tests.js', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /client/public/img/icons/icon-apple-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-apple-114x114.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-apple-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-apple-120x120.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-apple-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-apple-144x144.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-apple-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-apple-152x152.png -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/android-chrome-72x72.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-android-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-android-128x128.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-android-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-android-144x144.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-android-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-android-152x152.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-android-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-android-192x192.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-android-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-android-256x256.png -------------------------------------------------------------------------------- /client/public/img/icons/icon-android-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/icons/icon-android-512x512.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-apple-320x460.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-apple-320x460.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-apple-640x920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-apple-640x920.png -------------------------------------------------------------------------------- /server/src/graphql/base/resolvers/index.js: -------------------------------------------------------------------------------- 1 | const Date = require('./date'); 2 | 3 | const resolvers = { 4 | Date, 5 | }; 6 | 7 | module.exports = resolvers; 8 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/query/index.js: -------------------------------------------------------------------------------- 1 | const user = require('./user'); 2 | 3 | const Query = { 4 | user, 5 | }; 6 | 7 | module.exports = Query; 8 | -------------------------------------------------------------------------------- /client/public/img/splash/splash-android-240x320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-android-240x320.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-apple-1024x748.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-apple-1024x748.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-apple-1536x2008.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-apple-1536x2008.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-apple-2048x1496.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-apple-2048x1496.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-apple-640x1096.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-apple-640x1096.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-apple-768x1004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-apple-768x1004.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-android-1080x1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-android-1080x1920.png -------------------------------------------------------------------------------- /client/public/img/splash/splash-android-750x1334.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fede-rodes/crae-apollo-heroku/HEAD/client/public/img/splash/splash-android-750x1334.png -------------------------------------------------------------------------------- /server/src/startup/validation.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | // Extend Joi validator by adding objectId type 4 | Joi.objectId = require('joi-objectid')(Joi); 5 | -------------------------------------------------------------------------------- /server/src/graphql/base/index.js: -------------------------------------------------------------------------------- 1 | const typeDefs = require('./type-defs'); 2 | const resolvers = require('./resolvers'); 3 | 4 | module.exports = { 5 | typeDefs, 6 | resolvers, 7 | }; 8 | -------------------------------------------------------------------------------- /server/src/graphql/user/index.js: -------------------------------------------------------------------------------- 1 | const typeDefs = require('./type-defs'); 2 | const resolvers = require('./resolvers'); 3 | 4 | module.exports = { 5 | typeDefs, 6 | resolvers, 7 | }; 8 | -------------------------------------------------------------------------------- /server/src/graphql/subscription/index.js: -------------------------------------------------------------------------------- 1 | const typeDefs = require('./type-defs'); 2 | const resolvers = require('./resolvers'); 3 | 4 | module.exports = { 5 | typeDefs, 6 | resolvers, 7 | }; 8 | -------------------------------------------------------------------------------- /greenkeeper.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": { 3 | "default": { 4 | "packages": [ 5 | "client/package.json", 6 | "package.json", 7 | "server/package.json" 8 | ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/src/components/common/divider/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import Divider from './index'; 4 | 5 | storiesOf('Divider', module) 6 | .add('Divider', () => ); 7 | -------------------------------------------------------------------------------- /client/src/graphql/subscription/fragment/keys.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const keysFragment = gql` 4 | fragment keysFragment on Keys { 5 | auth 6 | p256dh 7 | } 8 | `; 9 | 10 | export default keysFragment; 11 | -------------------------------------------------------------------------------- /client/src/graphql/user/fragment/user.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const userFragment = gql` 4 | fragment userFragment on User { 5 | _id 6 | createdAt 7 | email 8 | } 9 | `; 10 | 11 | export default userFragment; 12 | -------------------------------------------------------------------------------- /server/src/utils/async-for-each.js: -------------------------------------------------------------------------------- 1 | const asyncForEach = async (array, callback) => { 2 | for (let index = 0; index < array.length; index += 1) { 3 | await callback(array[index], index, array); 4 | } 5 | }; 6 | 7 | module.exports = asyncForEach; 8 | -------------------------------------------------------------------------------- /client/src/components/common/loading/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import Loading from './index'; 4 | 5 | storiesOf('Loading', module) 6 | .add('Loading', () => ( 7 | 8 | )); 9 | -------------------------------------------------------------------------------- /client/src/components/route-wrappers/index.js: -------------------------------------------------------------------------------- 1 | // Exposed components 2 | export { default as ScrollToTop } from './scroll-to-top'; 3 | export { default as LoggedInRoute } from './logged-in-route'; 4 | export { default as RouteWithProps } from './route-with-props'; 5 | -------------------------------------------------------------------------------- /client/src/components/common/divider/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Divider = styled.div` 4 | height: 0px; 5 | border: 1px solid ${({ theme }) => (theme.color.greyLight)}; 6 | width: 100%; 7 | `; 8 | 9 | export default Divider; 10 | -------------------------------------------------------------------------------- /client/src/storyshots.test.js: -------------------------------------------------------------------------------- 1 | import initStoryshots from '@storybook/addon-storyshots'; 2 | 3 | // console.error = (...args) => { throw new Error(args); }; 4 | // console.warn = (...args) => { throw new Error(args); }; 5 | 6 | initStoryshots({ /* configuration options */ }); 7 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/index.js: -------------------------------------------------------------------------------- 1 | const User = require('./user'); 2 | const Query = require('./query'); 3 | const Mutation = require('./mutation'); 4 | 5 | const resolvers = { 6 | User, 7 | Query, 8 | Mutation, 9 | }; 10 | 11 | module.exports = resolvers; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .yarnclean 15 | npm-debug.log 16 | yarn-error.log 17 | *.rdb 18 | -------------------------------------------------------------------------------- /client/src/components/common/title/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import Title from './index'; 4 | 5 | storiesOf('Title', module) 6 | .add('Title', () => ( 7 | 8 | I'm the Title 9 | 10 | )); 11 | -------------------------------------------------------------------------------- /client/src/graphql/subscription/mutation/send-push-notification.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const sendPushNotificationMutation = gql` 4 | mutation { 5 | sendPushNotification { 6 | _id 7 | } 8 | } 9 | `; 10 | 11 | export default sendPushNotificationMutation; 12 | -------------------------------------------------------------------------------- /client/src/graphql/user/query/user.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import userFragment from '../fragment/user'; 3 | 4 | const userQuery = gql` 5 | query { 6 | user { 7 | ...userFragment 8 | } 9 | } 10 | ${userFragment} 11 | `; 12 | 13 | export default userQuery; 14 | -------------------------------------------------------------------------------- /server/src/models/index.js: -------------------------------------------------------------------------------- 1 | const { User, validateSignup, validateLogin } = require('./user'); 2 | const { Subscription, validatePush } = require('./subscription'); 3 | 4 | module.exports = { 5 | User, 6 | validateSignup, 7 | validateLogin, 8 | Subscription, 9 | validatePush, 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/graphql/user/mutation/send-passcode.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const sendPasscodeMutation = gql` 4 | mutation sendPasscode($email: String!) { 5 | sendPasscode(email: $email) { 6 | _id 7 | } 8 | } 9 | `; 10 | 11 | export default sendPasscodeMutation; 12 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/mutation/index.js: -------------------------------------------------------------------------------- 1 | const signup = require('./signup'); 2 | const login = require('./login'); 3 | const sendPasscode = require('./send-passcode'); 4 | 5 | const Mutation = { 6 | signup, 7 | login, 8 | sendPasscode, 9 | }; 10 | 11 | module.exports = Mutation; 12 | -------------------------------------------------------------------------------- /client/src/graphql/user/mutation/signup.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const signupMutation = gql` 4 | mutation signup($email: String!) { 5 | signup(email: $email) { 6 | _id 7 | createdAt 8 | email 9 | } 10 | } 11 | `; 12 | 13 | export default signupMutation; 14 | -------------------------------------------------------------------------------- /client/src/pages/not-found-page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const NotFoundPage = () => ( 5 |
6 |

404 - Page Not Found

7 |

Back to Home

8 |
9 | ); 10 | 11 | export default NotFoundPage; 12 | -------------------------------------------------------------------------------- /server/src/graphql/all-schemas.js: -------------------------------------------------------------------------------- 1 | const Base = require('./base'); 2 | const User = require('./user'); 3 | const Subscription = require('./subscription'); 4 | 5 | // Add all your schemas here! 6 | const allSchemas = { 7 | Base, 8 | User, 9 | Subscription, 10 | }; 11 | 12 | module.exports = allSchemas; 13 | -------------------------------------------------------------------------------- /client/src/graphql/user/mutation/login.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const loginMutation = gql` 4 | mutation login($email: String!, $passcode: Int!) { 5 | login(email: $email, passcode: $passcode) { 6 | _id 7 | token 8 | } 9 | } 10 | `; 11 | 12 | export default loginMutation; 13 | -------------------------------------------------------------------------------- /server/src/graphql/subscription/resolvers/index.js: -------------------------------------------------------------------------------- 1 | // const Subscription = require('./subscription'); 2 | const Query = require('./query'); 3 | const Mutation = require('./mutation'); 4 | 5 | const resolvers = { 6 | // Subscription, 7 | Query, 8 | Mutation, 9 | }; 10 | 11 | module.exports = resolvers; 12 | -------------------------------------------------------------------------------- /server/src/middlewares/error.js: -------------------------------------------------------------------------------- 1 | const { logger } = require('../startup/logger'); 2 | 3 | const errorHandling = (exc, req, res, next) => { 4 | logger.error(exc.message || 'No msg field'); 5 | console.log('CATCH ALL ERRORS'); 6 | res.status(500).send('Something failed'); 7 | next(); 8 | }; 9 | 10 | module.exports = errorHandling; 11 | -------------------------------------------------------------------------------- /client/src/graphql/subscription/mutation/delete-subscription.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const deleteSubscriptionMutation = gql` 4 | mutation deleteSubscription($endpoint: String!) { 5 | deleteSubscription(endpoint: $endpoint) { 6 | _id 7 | } 8 | } 9 | `; 10 | 11 | export default deleteSubscriptionMutation; 12 | -------------------------------------------------------------------------------- /client/src/graphql/subscription/mutation/save-subscription.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const saveSubscriptionMutation = gql` 4 | mutation saveSubscription($subscription: SubscriptionInput!) { 5 | saveSubscription(subscription: $subscription) { 6 | _id 7 | } 8 | } 9 | `; 10 | 11 | export default saveSubscriptionMutation; 12 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | .env 7 | 8 | # misc 9 | .DS_Store 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | logfile.log -------------------------------------------------------------------------------- /client/src/graphql/mocks.js: -------------------------------------------------------------------------------- 1 | const resolvers = { 2 | Mutation: { 3 | login: (parent, { email, passcode }) => { 4 | if (email && email === 'email@example.com' && passcode && passcode === 123456) { 5 | return { _id: '123', token: 'xyz123' }; 6 | } 7 | throw new Error(); 8 | }, 9 | }, 10 | }; 11 | 12 | export default resolvers; 13 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/query/user.js: -------------------------------------------------------------------------------- 1 | const { User } = require('../../../../models'); 2 | 3 | const user = (root, args, ctx) => { 4 | const { usr } = ctx; 5 | 6 | if (!usr || !usr._id) { 7 | return null; 8 | } 9 | 10 | // Query current logged in user 11 | return User.findById({ _id: usr._id }); 12 | }; 13 | 14 | module.exports = user; 15 | -------------------------------------------------------------------------------- /server/src/services/push/index.js: -------------------------------------------------------------------------------- 1 | const send = require('./send'); 2 | 3 | const pushAPI = {}; 4 | 5 | //------------------------------------------------------------------------------ 6 | pushAPI.send = async (args) => { 7 | await send(args); 8 | }; 9 | //------------------------------------------------------------------------------ 10 | 11 | module.exports = pushAPI; 12 | -------------------------------------------------------------------------------- /client/src/components/common/feedback/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | // import { shallow } from 'enzyme'; 4 | import Feedback from '.'; 5 | 6 | describe('Feedback', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(, div); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /client/src/components/common/loading/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | // import { shallow } from 'enzyme'; 4 | import Loading from '.'; 5 | 6 | describe('Loading', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(, div); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /client/src/theme/sc.js: -------------------------------------------------------------------------------- 1 | // Styled components theme 2 | const theme = { 3 | color: { 4 | default: 'grey', 5 | primary: 'blue', 6 | danger: 'tomato', 7 | dangerLight: '#fcdbd9', 8 | success: 'green', 9 | successLight: '#cfefdf', 10 | link: 'blue', 11 | }, 12 | fontSize: { 13 | small: '14px', 14 | normal: '16px', 15 | }, 16 | }; 17 | 18 | export default theme; 19 | -------------------------------------------------------------------------------- /server/src/graphql/subscription/resolvers/mutation/index.js: -------------------------------------------------------------------------------- 1 | const saveSubscription = require('./save-subscription'); 2 | const deleteSubscription = require('./delete-subscription'); 3 | const sendPushNotification = require('./send-push-notification'); 4 | 5 | const Mutation = { 6 | saveSubscription, 7 | deleteSubscription, 8 | sendPushNotification, 9 | }; 10 | 11 | module.exports = Mutation; 12 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | .env 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/src/graphql/subscription/fragment/subscription.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import keysFragment from './keys'; 3 | 4 | const subscriptionFragment = gql` 5 | fragment subscriptionFragment on Subscription { 6 | _id 7 | createdAt 8 | userId 9 | endpoint 10 | keys { 11 | ...keysFragment 12 | } 13 | } 14 | ${keysFragment} 15 | `; 16 | 17 | export default subscriptionFragment; 18 | -------------------------------------------------------------------------------- /client/src/components/header-title/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route } from 'react-router-dom'; 3 | 4 | // TODO: if the user isn't logged in, display Welcome at home page 5 | const HeaderTitle = () => ( 6 | 7 | Home} /> 8 | Not Found} /> 9 | 10 | ); 11 | 12 | export default HeaderTitle; 13 | -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "rules": { 9 | "no-console": "off", 10 | "no-underscore-dangle": [ 11 | "error", 12 | { 13 | "allow": [ 14 | "_id", 15 | "__parseValue", 16 | "__serialize", 17 | "__parseLiteral", 18 | ] 19 | } 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/components/common/subtitle/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { action } from '@storybook/addon-actions'; 4 | import Subtitle from './index'; 5 | 6 | storiesOf('Subtitle', module) 7 | .add('Subtitle', () => ( 8 | 14 | )); 15 | -------------------------------------------------------------------------------- /client/src/render-props/index.js: -------------------------------------------------------------------------------- 1 | export { default as ServiceProps, servicePropTypes } from './service-props'; 2 | export { default as DisabledProps, disabledPropTypes } from './disabled-props'; 3 | export { default as MessageProps, messagePropTypes } from './message-props'; 4 | export { default as HookProps, hookPropTypes } from './hook-props'; 5 | export { default as FormProps, formPropTypes } from './form-props'; 6 | export { default as PWABtnProps, pwaBtnPropTypes } from './pwa-btn-props'; 7 | -------------------------------------------------------------------------------- /client/src/components/common/alert/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import Alert from './index'; 4 | 5 | storiesOf('Alert', module) 6 | .add('Alert success', () => ( 7 | 8 | )) 9 | .add('Alert error', () => ( 10 | 11 | )) 12 | .add('Alert no content', () => ( 13 | 14 | )); 15 | -------------------------------------------------------------------------------- /client/src/theme/mui.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | import red from '@material-ui/core/colors/red'; 3 | import green from '@material-ui/core/colors/green'; 4 | 5 | const theme = createMuiTheme({ 6 | typography: { 7 | useNextVariants: true, 8 | }, 9 | switch: { 10 | colorPrimary: green[500], 11 | trackOnColor: green[100], 12 | thumbOffColor: red[700], 13 | trackOffColor: red[100], 14 | }, 15 | }); 16 | 17 | export default theme; 18 | -------------------------------------------------------------------------------- /server/src/graphql/user/type-defs.js: -------------------------------------------------------------------------------- 1 | const typeDefs = ` 2 | type User { 3 | _id: ID! 4 | createdAt: Date! 5 | email: String! 6 | } 7 | 8 | type AuthToken { 9 | _id: ID! 10 | token: String! 11 | } 12 | 13 | type Query { 14 | user: User 15 | } 16 | 17 | type Mutation { 18 | signup(email: String!): User! 19 | login(email: String!, passcode: Int!): AuthToken! 20 | sendPasscode(email: String!): User! 21 | } 22 | `; 23 | 24 | module.exports = typeDefs; 25 | -------------------------------------------------------------------------------- /client/src/components/common/title/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | //------------------------------------------------------------------------------ 5 | // COMPONENT: 6 | //------------------------------------------------------------------------------ 7 | const Title = ({ children }) => ( 8 |

{children}

9 | ); 10 | 11 | Title.propTypes = { 12 | children: PropTypes.string.isRequired, 13 | }; 14 | 15 | export default Title; 16 | -------------------------------------------------------------------------------- /client/src/components/common/title/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { shallow } from 'enzyme'; 4 | import Title from '.'; 5 | 6 | describe('Title', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(Some text, div); 10 | }); 11 | 12 | it('h1 renders child text', () => { 13 | const wrapper = shallow(Some text); 14 | expect(wrapper.find('h1').text()).toEqual('Some text'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/app/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { shallow } from 'enzyme'; 4 | import App from './index'; 5 | 6 | const component = () =>
Hi!
; 7 | 8 | describe('App component', () => { 9 | it('renders without crashing', () => { 10 | const div = document.createElement('div'); 11 | ReactDOM.render(, div); 12 | }); 13 | 14 | it('renders', () => { 15 | const wrapper = shallow(); 16 | expect(wrapper.exists()).toBe(true); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/common/divider/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | // import { shallow } from 'enzyme'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import scTheme from '../../../theme/sc'; 6 | import Divider from '.'; 7 | 8 | describe('Divider', () => { 9 | it('renders without crashing', () => { 10 | const div = document.createElement('div'); 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | div, 16 | ); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/services/push/config.js: -------------------------------------------------------------------------------- 1 | const webPush = require('web-push'); 2 | 3 | /** 4 | * @see {@link https://developers.google.com/web/fundamentals/push-notifications/sending-messages-with-web-push-libraries} 5 | * @see {@link https://www.npmjs.com/package/web-push} 6 | */ 7 | 8 | const { 9 | GCM_PRIVATE_KEY, 10 | VAPID_SUBJECT, 11 | VAPID_PUBLIC_KEY, 12 | VAPID_PRIVATE_KEY, 13 | } = process.env; 14 | 15 | // Set web-push keys 16 | webPush.setGCMAPIKey(GCM_PRIVATE_KEY); 17 | webPush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); 18 | 19 | module.exports = webPush; 20 | -------------------------------------------------------------------------------- /server/src/services/nodemailer/config.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer'); 2 | 3 | const { 4 | SMTP_HOST, 5 | SMTP_USERNAME, 6 | SMTP_PASSWORD, 7 | SMTP_PORT, 8 | } = process.env; 9 | 10 | // Create reusable transporter object using the default SMTP transport 11 | const transporter = nodemailer.createTransport({ 12 | host: SMTP_HOST, 13 | port: SMTP_PORT, 14 | secure: false, // true for 465, false for other ports 15 | auth: { 16 | user: SMTP_USERNAME, 17 | pass: SMTP_PASSWORD, 18 | }, 19 | }); 20 | 21 | module.exports = { 22 | nodemailer, 23 | transporter, 24 | }; 25 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "es6": true, 7 | "jest": true 8 | }, 9 | "rules": { 10 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], 11 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 12 | "react/jsx-one-expression-per-line": "off", 13 | "indent": ["error", 2], 14 | "no-console": "off", 15 | "no-underscore-dangle": [ 16 | "error", 17 | { 18 | "allow": [ 19 | "_id", 20 | ] 21 | } 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/components/common/subtitle/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | //------------------------------------------------------------------------------ 5 | // COMPONENT: 6 | //------------------------------------------------------------------------------ 7 | const Subtitle = ({ text, link }) => ( 8 |

9 | {text} {link || null} 10 |

11 | ); 12 | 13 | Subtitle.propTypes = { 14 | text: PropTypes.string.isRequired, 15 | link: PropTypes.node, 16 | }; 17 | 18 | Subtitle.defaultProps = { 19 | link: null, 20 | }; 21 | 22 | export default Subtitle; 23 | -------------------------------------------------------------------------------- /client/src/components/common/feedback/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import Feedback from './index'; 4 | 5 | storiesOf('Feedback', module) 6 | .add('Error', () => ( 7 | 12 | )) 13 | .add('Success', () => ( 14 | 19 | )) 20 | .add('Loading', () => ( 21 | 26 | )); 27 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie9'; // For IE 9-11 support 2 | import 'react-app-polyfill/ie11'; // For IE 11 support 3 | import 'unfetch/polyfill'; 4 | // 'fetch' is now installed globally if it wasn't already available 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import App from './app'; 8 | import HeaderTitle from './components/header-title'; 9 | import Routes from './routes'; 10 | import registerServiceWorker from './register-sw'; 11 | 12 | ReactDOM.render(, document.getElementById('header-title')); 13 | ReactDOM.render(, document.getElementById('main')); 14 | registerServiceWorker(); 15 | -------------------------------------------------------------------------------- /server/src/graphql/subscription/resolvers/mutation/delete-subscription.js: -------------------------------------------------------------------------------- 1 | const { Subscription } = require('../../../../models'); 2 | 3 | //------------------------------------------------------------------------------ 4 | const deleteSubscription = async (root, args, ctx) => { 5 | const { endpoint } = args; 6 | const { usr } = ctx; 7 | 8 | // User logged in state validation was moved to Subscription model 9 | const sub = await Subscription.deleteByEndpoint({ user: usr, endpoint }); 10 | 11 | // Return the deleted subscription 12 | return sub; 13 | }; 14 | //------------------------------------------------------------------------------ 15 | 16 | module.exports = deleteSubscription; 17 | -------------------------------------------------------------------------------- /client/src/components/common/subtitle/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { shallow } from 'enzyme'; 4 | import Subtitle from '.'; 5 | 6 | describe('Subtitle', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(, div); 10 | }); 11 | 12 | it('renders text and link', () => { 13 | const wrapper = shallow( 14 | Click me} 17 | />, 18 | ); 19 | expect(wrapper.find('p').find('span').text()).toEqual('Some text'); 20 | expect(wrapper.find('a').text()).toEqual('Click me'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /server/src/models/user/test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const jwt = require('jsonwebtoken'); 3 | const { User } = require('.'); 4 | 5 | const { JWT_PRIVATE_KEY } = process.env; 6 | 7 | describe('user.genAuthToken', () => { 8 | it('should generate a valid JSON Web Token', () => { 9 | const _id = mongoose.Types.ObjectId().toHexString(); 10 | // OBS: new User({...}) doesn't save the user into the DB, only in memory 11 | const user = new User({ _id, email: 'email@example.com' }); 12 | // Don't call user.save() to avoid storing data into DB 13 | const token = user.genAuthToken(); 14 | const decoded = jwt.verify(token, JWT_PRIVATE_KEY); 15 | expect(decoded).toMatchObject({ _id }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /client/sw-precache-config.js: -------------------------------------------------------------------------------- 1 | // See package.json build script 2 | // https://stackoverflow.com/questions/47636757/add-more-service-worker-functionality-with-create-react-app?rq=1 3 | // https://medium.freecodecamp.org/how-to-build-a-pwa-with-create-react-app-and-custom-service-workers-376bd1fdc6d3 4 | module.exports = { 5 | staticFileGlobs: [ 6 | 'build/static/css/**.css', 7 | 'build/static/js/**.js', 8 | ], 9 | swFilePath: './build/service-worker.js', 10 | templateFilePath: './service-worker.tmpl', 11 | stripPrefix: 'build/', 12 | handleFetch: false, 13 | runtimeCaching: [{ 14 | urlPattern: /this\\.is\\.a\\.regex/, 15 | handler: 'networkFirst', 16 | }], 17 | importScripts: [ 18 | './push-listener.js', 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /server/src/graphql/subscription/resolvers/mutation/save-subscription.js: -------------------------------------------------------------------------------- 1 | const { Subscription } = require('../../../../models'); 2 | 3 | //------------------------------------------------------------------------------ 4 | const saveSubscription = async (root, args, ctx) => { 5 | const { subscription } = args; 6 | const { endpoint, keys } = subscription; 7 | const { usr } = ctx; 8 | 9 | // User logged in state validation was moved to Subscription model 10 | const newSub = await Subscription.createSubscription({ user: usr, endpoint, keys }); 11 | 12 | // Return the recently created subscription 13 | return newSub; 14 | }; 15 | //------------------------------------------------------------------------------ 16 | 17 | module.exports = saveSubscription; 18 | -------------------------------------------------------------------------------- /client/src/components/common/button-link/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import ButtonLink from './index'; 4 | 5 | storiesOf('ButtonLink', module) 6 | .add('ButtonLink default', () => ( 7 | 8 | I'm the content 9 | 10 | )) 11 | .add('ButtonLink disabled', () => ( 12 | 13 | I'm the content 14 | 15 | )) 16 | .add('ButtonLink no underline', () => ( 17 | 18 | I'm the content 19 | 20 | )) 21 | .add('ButtonLink no underline disabled', () => ( 22 | 23 | I'm the content 24 | 25 | )); 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8.12.0 4 | cache: 5 | directories: 6 | - node_modules 7 | yarn: true 8 | before_install: 9 | - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.12.3 10 | - export PATH="$HOME/.yarn/bin:$PATH" 11 | env: 12 | global: 13 | - CC_TEST_REPORTER_ID=baa0f13cfba0659a9712adf7e6bbf51dd47061804e20ec71156aea22417b8590 14 | before_script: 15 | - cd client && echo "SKIP_PREFLIGHT_CHECK=true" > .env 16 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 17 | - chmod +x ./cc-test-reporter 18 | - ./cc-test-reporter before-build 19 | script: 20 | - yarn run build 21 | - yarn test 22 | after_script: 23 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT -------------------------------------------------------------------------------- /server/src/graphql/subscription/type-defs.js: -------------------------------------------------------------------------------- 1 | const typeDefs = ` 2 | type Keys { 3 | auth: String! 4 | p256dh: String! 5 | } 6 | 7 | type Subscription { 8 | _id: ID! 9 | createdAt: Date 10 | userId: ID 11 | endpoint: String 12 | keys: Keys 13 | } 14 | 15 | input KeysInput { 16 | auth: String! 17 | p256dh: String! 18 | } 19 | 20 | input SubscriptionInput { 21 | endpoint: String! 22 | keys: KeysInput! 23 | } 24 | 25 | type Query { 26 | subscriptions(userId: ID!): [Subscription] 27 | } 28 | 29 | type Mutation { 30 | saveSubscription(subscription: SubscriptionInput!): Subscription 31 | deleteSubscription(endpoint: String!): Subscription 32 | sendPushNotification: [Subscription] 33 | } 34 | `; 35 | 36 | module.exports = typeDefs; 37 | -------------------------------------------------------------------------------- /client/src/components/common/alert/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { shallow } from 'enzyme'; 4 | import Alert from '.'; 5 | 6 | describe('Alert', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(, div); 10 | }); 11 | 12 | it('renders null when no content is provided', () => { 13 | const wrapper = shallow(); 14 | expect(wrapper.html()).toBeNull(); 15 | }); 16 | 17 | it('renders content when content is provided', () => { 18 | const wrapper = shallow( 19 | , 23 | ); 24 | expect(wrapper.text()).toBe('some msg'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /client/src/components/route-wrappers/route-with-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route } from 'react-router-dom'; 4 | 5 | //------------------------------------------------------------------------------ 6 | // COMPONENT: 7 | //------------------------------------------------------------------------------ 8 | /** 9 | * @summary Pass props down to child component. 10 | */ 11 | const RouteWithProps = ({ component: Component, ...rest }) => ( 12 | { 15 | const childProps = { ...rest, ...props }; 16 | return ; 17 | }} 18 | /> 19 | ); 20 | 21 | RouteWithProps.propTypes = { 22 | component: PropTypes.func.isRequired, 23 | }; 24 | 25 | export default RouteWithProps; 26 | -------------------------------------------------------------------------------- /server/src/fixtures.js: -------------------------------------------------------------------------------- 1 | const { User } = require('./models'); 2 | 3 | // Clear DB 4 | const clearAll = async () => { 5 | await User.deleteMany({}); 6 | }; 7 | 8 | // Populate DB. 9 | const fixtures = async () => { 10 | const user = await User.findOne({}).exec(); 11 | 12 | // Insert a user in case users collection is empty 13 | if (user) { 14 | console.log('\nTest user already exists!'); 15 | return; 16 | } 17 | 18 | // Insert test user 19 | const firstUser = new User({ 20 | email: 'federodes@gmail.com', 21 | }); 22 | 23 | try { 24 | await firstUser.save(); 25 | console.log('\nFirst user inserted!'); 26 | } catch (exc) { 27 | console.log(exc); 28 | } 29 | }; 30 | 31 | const initDB = async () => { 32 | // await clearAll(); 33 | // await fixtures(); 34 | }; 35 | 36 | module.exports = initDB; 37 | -------------------------------------------------------------------------------- /client/src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route } from 'react-router-dom'; 3 | import { propType } from 'graphql-anywhere'; 4 | import { withUser } from './global-data-provider'; 5 | import userFragment from './graphql/user/fragment/user'; 6 | import { ScrollToTop, LoggedInRoute } from './components/route-wrappers'; 7 | import HomePage from './pages/home-page'; 8 | import NotFoundPage from './pages/not-found-page'; 9 | 10 | const Routes = props => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | Routes.propTypes = { 20 | curUser: propType(userFragment), // eslint-disable-line 21 | }; 22 | 23 | Routes.defaultProps = { 24 | curUser: null, 25 | }; 26 | 27 | export default withUser(Routes); 28 | -------------------------------------------------------------------------------- /client/src/graphql/apollo-mock-client.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client'; 2 | import { SchemaLink } from 'apollo-link-schema'; 3 | import { InMemoryCache } from 'apollo-cache-inmemory'; 4 | import { buildClientSchema } from 'graphql/utilities/buildClientSchema'; 5 | import { addResolveFunctionsToSchema } from 'graphql-tools'; 6 | import resolvers from './mocks'; 7 | 8 | /** 9 | * @see {@link https://www.robinwieruch.de/graphql-server-mock-apollo-client/} 10 | * @see {@link https://www.apollographql.com/docs/graphql-tools/mocking.html} 11 | */ 12 | 13 | // Read schema from file 14 | const schema = buildClientSchema(require('./schema.json')); 15 | 16 | // Add mocked resolvers 17 | addResolveFunctionsToSchema({ schema, resolvers }); 18 | 19 | const mockClient = new ApolloClient({ 20 | link: new SchemaLink({ schema }), 21 | cache: new InMemoryCache(), 22 | }); 23 | 24 | export default mockClient; 25 | -------------------------------------------------------------------------------- /server/src/graphql/base/resolvers/date.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Date resolver 3 | * @summary In all our models we're using the default Date data type. As Apollo 4 | * basically only supports strings and numbers to be transported, we define a 5 | * new scalar which is basically another type. 6 | * @see {@link https://janikvonrotz.ch/2016/10/09/graphql-with-apollo-meteor-and-react/} 7 | */ 8 | const Date = {}; 9 | 10 | //------------------------------------------------------------------------------ 11 | // Value from the client 12 | Date.__parseValue = value => new Date(value); 13 | //------------------------------------------------------------------------------ 14 | // Value sent to the client 15 | Date.__serialize = value => value.toISOString(); 16 | //------------------------------------------------------------------------------ 17 | Date.__parseLiteral = ast => ast.value; 18 | //------------------------------------------------------------------------------ 19 | 20 | module.exports = Date; 21 | -------------------------------------------------------------------------------- /client/.storybook/config.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider } from 'styled-components'; 3 | import { ApolloProvider } from 'react-apollo'; 4 | import { configure, addDecorator } from '@storybook/react'; 5 | import { MuiThemeProvider } from '@material-ui/core/styles'; 6 | import requireContext from 'require-context.macro'; 7 | import mockClient from '../src/graphql/apollo-mock-client'; 8 | import scTheme from '../src/theme/sc'; 9 | import muiTheme from '../src/theme/mui'; 10 | 11 | // See https://github.com/storybooks/storybook/pull/5015 12 | const req = requireContext('../src', true, /stories\.(js)$/); 13 | 14 | function loadStories() { 15 | req.keys().forEach(req); 16 | } 17 | 18 | addDecorator((story) => ( 19 | 20 | 21 | 22 | {story()} 23 | 24 | 25 | 26 | )); 27 | 28 | configure(loadStories, module); 29 | -------------------------------------------------------------------------------- /client/src/components/header-title/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { mount } from 'enzyme'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | import HeaderTitle from '.'; 6 | 7 | const renderTitle = path => mount( 8 | 9 | 10 | , 11 | ); 12 | 13 | describe('HeaderTitle', () => { 14 | it('renders without crashing', () => { 15 | const div = document.createElement('div'); 16 | const wrapper = renderTitle('/'); 17 | ReactDOM.render(wrapper, div); 18 | wrapper.unmount(); 19 | }); 20 | 21 | it('renders Not Found if when visiting unknown path', () => { 22 | const wrapper = renderTitle('/random'); 23 | expect(wrapper.text()).toBe('Not Found'); 24 | wrapper.unmount(); 25 | }); 26 | 27 | it('renders Home if when visiting / path', () => { 28 | const wrapper = renderTitle('/'); 29 | expect(wrapper.text()).toBe('Home'); 30 | wrapper.unmount(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/components/common/feedback/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Loading from '../loading'; 4 | import Alert from '../alert'; 5 | 6 | //------------------------------------------------------------------------------ 7 | // COMPONENT: 8 | //------------------------------------------------------------------------------ 9 | const Feedback = ({ 10 | className, 11 | loading, 12 | errorMsg, 13 | successMsg, 14 | }) => ( 15 |
16 | {loading && } 17 | 18 | 19 |
20 | ); 21 | 22 | Feedback.propTypes = { 23 | className: PropTypes.string, 24 | loading: PropTypes.bool, 25 | errorMsg: PropTypes.string, 26 | successMsg: PropTypes.string, 27 | }; 28 | 29 | Feedback.defaultProps = { 30 | className: '', 31 | loading: false, 32 | errorMsg: '', 33 | successMsg: '', 34 | }; 35 | 36 | export default Feedback; 37 | -------------------------------------------------------------------------------- /server/src/startup/apollo-server.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server-express'); 2 | const jwt = require('express-jwt'); 3 | const schema = require('../graphql/exec-schema'); 4 | 5 | const { JWT_PRIVATE_KEY } = process.env; 6 | 7 | module.exports = (app) => { 8 | // See: https://blog.pusher.com/handling-authentication-in-graphql/ 9 | // Decode jwt and get user data (_id). Then reset req.user to decoded data. 10 | const authMiddleware = jwt({ 11 | secret: JWT_PRIVATE_KEY, 12 | credentialsRequired: false, // allow non-authenticated requests to pass through the middleware 13 | }); 14 | 15 | app.use(authMiddleware); 16 | 17 | const server = new ApolloServer({ 18 | schema, 19 | context: async ({ req }) => ({ 20 | usr: req.user, // user data is decoded on the authMiddleware 21 | }), 22 | // TODO" log errors to winston 23 | playground: { 24 | settings: { 25 | 'editor.theme': 'light', 26 | }, 27 | }, 28 | }); 29 | 30 | server.applyMiddleware({ app, path: '/graphql' }); 31 | }; 32 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/mutation/signup.js: -------------------------------------------------------------------------------- 1 | const pick = require('lodash/pick'); 2 | const { User, validateSignup } = require('../../../../models'); 3 | 4 | //------------------------------------------------------------------------------ 5 | // MUTATION: 6 | //------------------------------------------------------------------------------ 7 | const signup = async (root, args) => { 8 | const { email } = args; 9 | 10 | const { error } = validateSignup({ email }); 11 | if (error) { 12 | console.log('INVALID SIGNUP CREDENTIALS', error); 13 | throw new Error(error.details[0].message); // Bad request - 400 14 | } 15 | 16 | // Make sure user doesn't exist already 17 | const user = await User.findByEmail({ email }); 18 | if (user) { 19 | console.log('USER ALREADY REGISTERED', user); 20 | throw new Error('Email already in use'); // Bad request - 400 21 | } 22 | 23 | const newUser = await User.createUser({ email }); 24 | return pick(newUser, ['_id', 'createdAt', 'email']); // Success request 25 | }; 26 | 27 | module.exports = signup; 28 | -------------------------------------------------------------------------------- /client/public/js/offline.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // Source: https://dzone.com/articles/introduction-to-progressive-web-apps-offline-first 3 | (function () { 4 | 'use strict'; 5 | var header = document.querySelector('header'); 6 | var menuHeader = document.querySelector('.menu__header'); 7 | 8 | // After DOM Loaded 9 | document.addEventListener('DOMContentLoaded', function(event) { 10 | // On initial load to check connectivity 11 | if (!navigator.onLine) { 12 | updateNetworkStatus(); 13 | } 14 | window.addEventListener('online', updateNetworkStatus, false); 15 | window.addEventListener('offline', updateNetworkStatus, false); 16 | }); 17 | 18 | // To update network status 19 | function updateNetworkStatus() { 20 | if (navigator.onLine) { 21 | header.classList.remove('app__offline'); 22 | menuHeader.style.background = '#1E88E5'; 23 | } 24 | else { 25 | toast('You are offline!'); 26 | header.classList.add('app__offline'); 27 | menuHeader.style.background = '#9E9E9E'; 28 | } 29 | } 30 | })(); 31 | -------------------------------------------------------------------------------- /server/src/graphql/exec-schema.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema, addMockFunctionsToSchema } = require('graphql-tools'); 2 | const { typeDefs, resolvers } = require('./merge-schemas'); 3 | 4 | // Create our executable schema from merged schemas 5 | const logger = { log: e => console.error(e.stack) }; 6 | const schema = makeExecutableSchema({ typeDefs, resolvers, logger }); 7 | 8 | // When in test mode, mock apollo resolvers 9 | if (process.env.NODE_ENV === 'test') { 10 | // Here you could customize the mocks. 11 | // If you leave it empty, the default is used. 12 | // You can read more about mocking here: http://bit.ly/2pOYqXF 13 | // See: 14 | // https://www.apollographql.com/docs/graphql-tools/mocking.html#Default-mock-example 15 | // https://dev-blog.apollodata.com/mocking-your-server-with-just-one-line-of-code-692feda6e9cd 16 | const mocks = { 17 | Date: () => (new Date()), 18 | }; 19 | 20 | // This function call adds the mocks to your schema! 21 | addMockFunctionsToSchema({ schema, mocks, preserveResolvers: true }); 22 | } 23 | 24 | module.exports = schema; 25 | -------------------------------------------------------------------------------- /server/src/startup/db.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | const mongoose = require('mongoose'); 3 | const fixtures = require('../fixtures'); 4 | 5 | const { NODE_ENV, MONGO_URL, MONGO_URL_TEST } = process.env; 6 | 7 | console.log( 8 | '\nprocess.env.NODE_ENV', NODE_ENV, 9 | '\nprocess.env.MONGO_URL', MONGO_URL, 10 | '\nprocess.env.MONGO_URL_TEST', MONGO_URL_TEST, 11 | ); 12 | 13 | const MONGO = NODE_ENV === 'test' ? MONGO_URL_TEST : MONGO_URL; 14 | 15 | mongoose.connect(MONGO, { useNewUrlParser: true }); 16 | mongoose.Promise = global.Promise; 17 | 18 | const db = mongoose.connection; 19 | // OBS: don't catch error here. Let mongoose to throw so that we catch the exception using winston 20 | // db.on('error', console.error.bind(console, 'Connection error:')); 21 | db.once('open', console.log.bind(console, `Database connected to ${MONGO}`)); 22 | 23 | // Clean and populate DB 24 | fixtures(); 25 | 26 | // Required for graphql to properly parse ObjectId 27 | const { ObjectId } = mongoose.Types; 28 | ObjectId.prototype.valueOf = function () { 29 | return this.toString(); 30 | }; 31 | -------------------------------------------------------------------------------- /client/public/js/toast.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // Source: https://dzone.com/articles/introduction-to-progressive-web-apps-offline-first 3 | (function (exports) { 4 | 'use strict'; 5 | var toastContainer = document.querySelector('.toast__container'); 6 | 7 | // To show notification 8 | function toast(msg, options) { 9 | if (!msg) return; 10 | options = options || 3000; 11 | var toastMsg = document.createElement('div'); 12 | toastMsg.className = 'flex justify-between items-center toast__msg'; 13 | toastMsg.textContent = msg; 14 | toastContainer.appendChild(toastMsg); 15 | 16 | // Show toast for 3secs and hide it 17 | setTimeout(function () { 18 | toastMsg.classList.add('toast__msg--hide'); 19 | }, options); 20 | 21 | // Remove the element after hiding 22 | toastMsg.addEventListener('transitionend', function (event) { 23 | event.target.parentNode.removeChild(event.target); 24 | }); 25 | } 26 | 27 | // Make this method available in global 28 | exports.toast = toast; 29 | })(typeof window === 'undefined' ? module.exports : window); 30 | -------------------------------------------------------------------------------- /client/src/components/route-wrappers/scroll-to-top.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withRouter } from 'react-router-dom'; 4 | 5 | //------------------------------------------------------------------------------ 6 | // COMPONENT: 7 | //------------------------------------------------------------------------------ 8 | /** 9 | * @summary This component will scroll the window up on every navigation. 10 | */ 11 | class ScrollToTop extends React.Component { 12 | componentDidUpdate(prevProps) { 13 | const { location } = this.props; 14 | if (location !== prevProps.location) { 15 | window.scrollTo(0, 0); 16 | } 17 | } 18 | 19 | render() { 20 | const { children } = this.props; 21 | return children; 22 | } 23 | } 24 | 25 | ScrollToTop.propTypes = { 26 | location: PropTypes.object.isRequired, // eslint-disable-line 27 | children: PropTypes.oneOfType([ 28 | PropTypes.func, 29 | PropTypes.object, 30 | ]).isRequired, 31 | }; 32 | 33 | // withRouter provides access to location 34 | export default withRouter(ScrollToTop); 35 | -------------------------------------------------------------------------------- /server/src/graphql/merge-schemas.js: -------------------------------------------------------------------------------- 1 | const { mergeTypes } = require('merge-graphql-schemas'); 2 | const merge = require('lodash/merge'); 3 | const allSchemas = require('./all-schemas'); 4 | 5 | // Filter out those schemas for which 'typeDefs' and 'resolvers' are defined. In 6 | // the end we'll get something like the following: 7 | // const allTypeDefs = [Base.typeDefs, User.typeDefs, ...]; 8 | // const allResolvers = [Base.resolvers, User.resolvers, ...]; 9 | const allTypeDefs = []; 10 | const allResolvers = []; 11 | 12 | const keys = Object.keys(allSchemas); 13 | const { length } = keys; 14 | 15 | for (let i = 0; i < length; i += 1) { 16 | const key = keys[i]; 17 | const { typeDefs, resolvers } = allSchemas[key]; 18 | 19 | if (typeDefs && resolvers) { 20 | allTypeDefs.push(typeDefs); 21 | allResolvers.push(resolvers); 22 | } 23 | } 24 | 25 | // Merge all types and resolvers from allSchemas to create our executable schema 26 | const typeDefs = mergeTypes(allTypeDefs); 27 | const resolvers = merge(...allResolvers); 28 | 29 | module.exports = { 30 | typeDefs, 31 | resolvers, 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/pages/auth-page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SignupPage from '../signup-page'; 3 | import LoginPage from '../login-page'; 4 | 5 | //------------------------------------------------------------------------------ 6 | // COMPONENT: 7 | //------------------------------------------------------------------------------ 8 | // After PasscodeAuthView returns successful, the user logged-in-state will change 9 | // from 'logged out' to 'logged in' automatically. This will trigger the 10 | // LoggedOutRoute component's logic (said component wraps the AuthPage component) 11 | // which will result in redirecting the user to home page automatically. 12 | class AuthPage extends React.PureComponent { 13 | state = { 14 | page: 'login', // 'signup', 15 | } 16 | 17 | handlePageChange = (page) => { 18 | this.setState({ page }); 19 | } 20 | 21 | render() { 22 | const { page } = this.state; 23 | 24 | return page === 'login' 25 | ? 26 | : ; 27 | } 28 | } 29 | 30 | export default AuthPage; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crae-simple", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": {}, 6 | "license": "MIT", 7 | "engines": { 8 | "node": "8.12.0", 9 | "npm": "6.4.1", 10 | "yarn": "1.12.3" 11 | }, 12 | "scripts": { 13 | "build": "concurrently \"cd client && yarn build\" \"cd server && yarn build\"", 14 | "clean": "concurrently \"rimraf node_modules yarn.lock package-lock.json\" \"cd client && rimraf node_modules build yarn.lock package-lock.json\" \"cd server && rimraf node_modules build yarn.lock package-lock.json\"", 15 | "heroku-postbuild": "yarn build", 16 | "install": "(cd client && yarn) && (cd server && yarn)", 17 | "start": "concurrently \"cd client && yarn start\" \"cd server && yarn start\"", 18 | "start:prod": "cd server && yarn start:prod", 19 | "local-prod": "yarn clean && yarn install && yarn build && heroku local", 20 | "local": "yarn clean && yarn install && yarn start", 21 | "test": "(cd client && yarn test) && (cd server && yarn test)" 22 | }, 23 | "dependencies": { 24 | "concurrently": "^4.1.0", 25 | "rimraf": "^2.6.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/startup/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | require('winston-mongodb'); 3 | require('express-async-errors'); 4 | 5 | const { MONGO_URL } = process.env; 6 | 7 | const logger = winston.createLogger({ 8 | level: 'info', 9 | format: winston.format.combine( 10 | winston.format.timestamp(), 11 | winston.format.json(), 12 | ), 13 | transports: [ 14 | new winston.transports.Console({ colorize: true, prettyPrint: true }), 15 | new winston.transports.File({ filename: 'logfile.log' }), 16 | new winston.transports.MongoDB({ db: MONGO_URL }), 17 | ], 18 | }); 19 | 20 | const handleException = async (exc) => { 21 | await logger.error(exc.message || 'No msg field'); 22 | // TODO: send me an email 23 | console.log('TODO: SEND EMAIL TO OWNER'); 24 | // Something bad happened, kill the process and then restart fresh 25 | // TODO: use other winston transports 26 | process.exit(1); 27 | }; 28 | 29 | process.on('uncaughtException', handleException); 30 | process.on('unhandledRejection', handleException); 31 | 32 | // const p = Promise.reject(new Error('Ive been rejected :(')); 33 | // p.then(() => { console.log('done'); }); 34 | 35 | module.exports = { 36 | winston, 37 | logger, 38 | }; 39 | -------------------------------------------------------------------------------- /client/.sample.env: -------------------------------------------------------------------------------- 1 | # This file contains the ENV vars necessary to run the app locally using the 2 | # 'heroku local' command. 3 | # IMPORTANT: DO NOT store sensitive data here. 4 | # Use 'heroku config:set' command to define your environment variables in your 5 | # production or staging environment. For instance, you can run the following 6 | # command to set your graphql endpoint for an app that is deployed to heroku: 7 | # 8 | # heroku config:set REACT_APP_GRAPHQL_URI=https://YOUR_APP_NAME.herokuapp.com/graphql 9 | # 10 | # Then you will need to force heroku to rebuild the app so that the recently set 11 | # env var can be embedded during the next build of your Create React App (CRA). 12 | # Remember, CRA produces a static HTML/CSS/JS bundle, so it can’t read env vars 13 | # at runtime! You can do that by pushing an empty commit: 14 | # 15 | # git commit --allow-empty -m "This commit does not change any code" 16 | # git push heroku master 17 | # 18 | # For more information see this: https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-custom-environment-variables 19 | 20 | REACT_APP_GRAPHQL_URI=http://localhost:5000/graphql 21 | REACT_APP_VAPID_PUBLIC_KEY=xxx 22 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /client/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // react-testing-library renders your components to document.body, 2 | // this will ensure they're removed after each test. 3 | import 'react-testing-library/cleanup-after-each'; 4 | // this adds jest-dom's custom assertions 5 | import 'jest-dom/extend-expect'; 6 | import Enzyme from 'enzyme'; 7 | import Adapter from 'enzyme-adapter-react-16'; 8 | 9 | Enzyme.configure({ adapter: new Adapter() }); 10 | 11 | const localStorageMock = { 12 | getItem: jest.fn(), 13 | setItem: jest.fn(), 14 | clear: jest.fn(), 15 | }; 16 | global.localStorage = localStorageMock; 17 | 18 | /* 19 | OBS: below config is probably needed when using jest.mount 20 | 21 | const { JSDOM } = require('jsdom'); 22 | 23 | const jsdom = new JSDOM(''); 24 | const { window } = jsdom; 25 | 26 | function copyProps(src, target) { 27 | Object.defineProperties(target, { 28 | ...Object.getOwnPropertyDescriptors(src), 29 | ...Object.getOwnPropertyDescriptors(target), 30 | }); 31 | } 32 | 33 | global.window = window; 34 | global.document = window.document; 35 | global.navigator = { 36 | userAgent: 'node.js', 37 | }; 38 | global.requestAnimationFrame = function (callback) { 39 | return setTimeout(callback, 0); 40 | }; 41 | global.cancelAnimationFrame = function (id) { 42 | clearTimeout(id); 43 | }; 44 | copyProps(window, global); 45 | */ 46 | -------------------------------------------------------------------------------- /server/src/services/push/send.js: -------------------------------------------------------------------------------- 1 | const webPush = require('./config'); 2 | // const { validatePush } = require('../../models/subscription'); 3 | 4 | //------------------------------------------------------------------------------ 5 | // METHOD: 6 | //------------------------------------------------------------------------------ 7 | const send = async ({ 8 | subscription, 9 | title, 10 | body, 11 | icon, 12 | }) => { 13 | // console.log('\n\npushAPI.send args', args); 14 | /* const { error } = validatePush({ subscription, title, body, icon }); 15 | if (error) { 16 | console.log('\n\nerror', error); 17 | return { error: error.details[0].message }; 18 | } */ 19 | 20 | console.log( 21 | '\n******Send Push Notification******', 22 | '\nsubscription', subscription, 23 | '\ntitle', title, 24 | '\nbody', body, 25 | '\nicon', icon, 26 | ); 27 | 28 | const payload = JSON.stringify({ title, body, icon }); 29 | 30 | const options = { 31 | TTL: 60, // time to live in seconds 32 | }; 33 | 34 | try { 35 | await webPush.sendNotification(subscription, payload, options); 36 | console.log('\nPUSH NOTIFICATION DELIVERED SUCCESSFULLY!'); 37 | } catch (exc) { 38 | console.log(`\nError when trying to deliver PUSH NOTIFICATION for ${subscription.endpoint}`, exc); 39 | throw new Error(exc); 40 | } 41 | }; 42 | 43 | module.exports = send; 44 | -------------------------------------------------------------------------------- /client/src/layouts/auth-page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import Title from '../../components/common/title'; 5 | import Subtitle from '../../components/common/subtitle'; 6 | 7 | //------------------------------------------------------------------------------ 8 | // STYLE: 9 | //------------------------------------------------------------------------------ 10 | const MaxWidth = styled.div` 11 | max-width: 400px; 12 | `; 13 | //------------------------------------------------------------------------------ 14 | // COMPONENT: 15 | //------------------------------------------------------------------------------ 16 | const AuthPageLayout = ({ 17 | children, 18 | title, 19 | subtitle, 20 | link, 21 | }) => ( 22 | 23 | {title && {title}} 24 | {subtitle && } 25 | {children} 26 | 27 | ); 28 | 29 | AuthPageLayout.propTypes = { 30 | children: PropTypes.oneOfType([ 31 | PropTypes.func, 32 | PropTypes.node, 33 | ]).isRequired, 34 | title: PropTypes.string, 35 | subtitle: PropTypes.string, 36 | link: PropTypes.object, // eslint-disable-line 37 | }; 38 | 39 | AuthPageLayout.defaultProps = { 40 | title: '', 41 | subtitle: '', 42 | link: null, 43 | }; 44 | 45 | export default AuthPageLayout; 46 | -------------------------------------------------------------------------------- /client/src/components/common/button-link/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { shallow } from 'enzyme'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import scTheme from '../../../theme/sc'; 6 | import ButtonLink from '.'; 7 | 8 | describe('ButtonLink', () => { 9 | it('renders without crashing', () => { 10 | const div = document.createElement('div'); 11 | ReactDOM.render( 12 | 13 | 14 | Im a button 15 | 16 | , 17 | div, 18 | ); 19 | }); 20 | 21 | it('renders child text', () => { 22 | const wrapper = shallow( 23 | 24 | Im a button 25 | , 26 | ); 27 | expect(wrapper.text()).toEqual('Im a button'); 28 | }); 29 | 30 | it('accepts underline prop', () => { 31 | const wrapper = shallow( 32 | 33 | Im a button 34 | , 35 | ); 36 | 37 | expect(wrapper.props().underline).toBe('underline'); 38 | }); 39 | 40 | it('accepts onClick prop', () => { 41 | const handleClick = jest.fn(); 42 | 43 | const wrapper = shallow( 44 | 45 | Im a button 46 | , 47 | ); 48 | 49 | wrapper.simulate('click'); 50 | expect(wrapper.props().onClick).toHaveBeenCalled(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /client/src/components/route-wrappers/logged-in-route.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route } from 'react-router-dom'; 4 | import { propType } from 'graphql-anywhere'; 5 | import userFragment from '../../graphql/user/fragment/user'; 6 | import AuthPage from '../../pages/auth-page'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // COMPONENT: 10 | //------------------------------------------------------------------------------ 11 | /** 12 | * @summary Makes sure that the user that is trying to access the wrapped route 13 | * is authenticated. If not, the LoggedInRoute component renders the provided 14 | * the loginOverlay component on top of the current route. 15 | */ 16 | const LoggedInRoute = ({ curUser, component: Component, ...rest }) => ( 17 | { 20 | const childProps = { curUser, ...rest, ...ownProps }; 21 | 22 | // If user is NOT logged in, resolve 23 | if (!curUser) { 24 | return ; 25 | } 26 | 27 | // Otherwise, render the requested component 28 | return ; 29 | }} 30 | /> 31 | ); 32 | 33 | LoggedInRoute.propTypes = { 34 | curUser: propType(userFragment), 35 | component: PropTypes.func.isRequired, 36 | }; 37 | 38 | LoggedInRoute.defaultProps = { 39 | curUser: null, 40 | }; 41 | 42 | export default LoggedInRoute; 43 | -------------------------------------------------------------------------------- /client/src/render-props/disabled-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | //------------------------------------------------------------------------------ 5 | // PROPS AND METHODS PROVIDER: 6 | //------------------------------------------------------------------------------ 7 | class DisabledProps extends React.PureComponent { 8 | state = { 9 | disabled: false, 10 | } 11 | 12 | disableBtn = () => { 13 | this.setState(() => ({ disabled: true })); 14 | } 15 | 16 | enableBtn = () => { 17 | this.setState(() => ({ disabled: false })); 18 | } 19 | 20 | render() { 21 | const { children } = this.props; 22 | const { disabled } = this.state; 23 | 24 | // Public API 25 | const api = { 26 | disabled, 27 | disableBtn: this.disableBtn, 28 | enableBtn: this.enableBtn, 29 | }; 30 | 31 | return children(api); 32 | } 33 | } 34 | 35 | DisabledProps.propTypes = { 36 | children: PropTypes.oneOfType([ 37 | PropTypes.func, 38 | PropTypes.object, 39 | ]).isRequired, 40 | }; 41 | 42 | export default DisabledProps; 43 | 44 | //------------------------------------------------------------------------------ 45 | // PROP TYPES: 46 | //------------------------------------------------------------------------------ 47 | export const disabledPropTypes = { 48 | disabled: PropTypes.bool.isRequired, 49 | disableBtn: PropTypes.func.isRequired, 50 | enableBtn: PropTypes.func.isRequired, 51 | }; 52 | -------------------------------------------------------------------------------- /client/src/graphql/apollo-client.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client'; 2 | import { createHttpLink } from 'apollo-link-http'; 3 | import { setContext } from 'apollo-link-context'; 4 | import { InMemoryCache } from 'apollo-cache-inmemory'; 5 | 6 | // REACT_APP_GRAPHQL_URI is defined in .env file. When the app is deployed to 7 | // heroku, the REACT_APP_GRAPHQL_URI env variable needs to be reset to point to 8 | // https://YOUR-APP-NAME.herokuapp.com/graphql (this will have precedence over 9 | // the default value provided in the .env file). See the .env file on how to do 10 | // this. 11 | const { NODE_ENV, REACT_APP_GRAPHQL_URI } = process.env; 12 | 13 | const isNotProduction = NODE_ENV !== 'production'; 14 | const uri = isNotProduction ? 'http://localhost:3001/graphql' : REACT_APP_GRAPHQL_URI; 15 | 16 | // Log 17 | console.log('\nNODE_ENV', NODE_ENV, '\nGRAPHQL_URI', uri); 18 | 19 | const httpLink = createHttpLink({ uri }); 20 | 21 | const authLink = setContext((_, { headers }) => { 22 | // Get the authentication token from local storage if it exists 23 | const token = localStorage.getItem('x-auth-token'); 24 | // Return the headers to the context so httpLink can read them 25 | return { 26 | headers: { 27 | ...headers, 28 | authorization: token ? `Bearer ${token}` : '', 29 | }, 30 | }; 31 | }); 32 | 33 | const client = new ApolloClient({ 34 | link: authLink.concat(httpLink), 35 | cache: new InMemoryCache(), 36 | }); 37 | 38 | export default client; 39 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "nodemon app.js", 8 | "build": "babel . --ignore node_modules,build --out-dir build", 9 | "start:prod": "node build/app.js", 10 | "lint": "eslint .", 11 | "test": "jest --watchAll --verbose false --coverage" 12 | }, 13 | "dependencies": { 14 | "apollo-server-express": "^2.2.3", 15 | "babel-cli": "^6.26.0", 16 | "bcrypt": "^3.0.2", 17 | "casual": "^1.5.19", 18 | "cors": "^2.8.5", 19 | "dotenv": "^6.1.0", 20 | "express": "^4.16.4", 21 | "express-async-errors": "^3.1.1", 22 | "express-jwt": "^5.3.1", 23 | "graphql": "^14.0.2", 24 | "graphql-tools": "^4.0.3", 25 | "helmet": "^3.15.0", 26 | "joi": "^14.3.0", 27 | "joi-objectid": "^2.0.0", 28 | "jsonwebtoken": "^8.4.0", 29 | "lodash": "^4.17.11", 30 | "merge-graphql-schemas": "^1.5.8", 31 | "moment": "^2.22.2", 32 | "mongoose": "^5.3.13", 33 | "morgan": "^1.9.1", 34 | "nodemailer": "^5.0.0", 35 | "validator": "^10.9.0", 36 | "web-push": "^3.3.3", 37 | "winston": "^3.1.0", 38 | "winston-mongodb": "^4.0.3" 39 | }, 40 | "devDependencies": { 41 | "eslint": "^5.9.0", 42 | "eslint-config-airbnb": "17.1.0", 43 | "eslint-plugin-import": "^2.14.0", 44 | "eslint-plugin-jsx-a11y": "^6.1.2", 45 | "eslint-plugin-react": "^7.11.1", 46 | "jest": "^23.6.0", 47 | "nodemon": "^1.18.6" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/render-props/service-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | //------------------------------------------------------------------------------ 5 | // PROPS AND METHODS PROVIDER: 6 | //------------------------------------------------------------------------------ 7 | class ServiceProps extends React.PureComponent { 8 | state = { 9 | service: '', // auth service type: 'password' or 'facebook' 10 | } 11 | 12 | setService = (service) => { 13 | this.setState(() => ({ service })); 14 | } 15 | 16 | clearService = () => { 17 | this.setState(() => ({ service: '' })); 18 | } 19 | 20 | render() { 21 | const { children } = this.props; 22 | const { service } = this.state; 23 | 24 | // Public API 25 | const api = { 26 | service, 27 | setService: this.setService, 28 | clearService: this.clearService, 29 | }; 30 | 31 | return children(api); 32 | } 33 | } 34 | 35 | ServiceProps.propTypes = { 36 | children: PropTypes.oneOfType([ 37 | PropTypes.func, 38 | PropTypes.object, 39 | ]).isRequired, 40 | }; 41 | 42 | export default ServiceProps; 43 | 44 | //------------------------------------------------------------------------------ 45 | // PROP TYPES: 46 | //------------------------------------------------------------------------------ 47 | export const servicePropTypes = { 48 | service: PropTypes.string.isRequired, 49 | setService: PropTypes.func.isRequired, 50 | clearService: PropTypes.func.isRequired, 51 | }; 52 | -------------------------------------------------------------------------------- /client/src/components/auth/send-passcode.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import sendPasscodeMutation from '../../graphql/user/mutation/send-passcode'; 5 | 6 | //------------------------------------------------------------------------------ 7 | // COMPONENT: 8 | //------------------------------------------------------------------------------ 9 | class SendPasscode extends React.PureComponent { 10 | handleSend = async ({ email }) => { 11 | const { onSendError, onSendSuccess, sendPasscode } = this.props; 12 | 13 | try { 14 | await sendPasscode({ variables: { email } }); 15 | onSendSuccess(); 16 | } catch (exc) { 17 | onSendError(exc); 18 | } 19 | } 20 | 21 | render() { 22 | const { children } = this.props; 23 | 24 | // Public API 25 | const api = { 26 | sendPasscode: this.handleSend, 27 | }; 28 | 29 | return children(api); 30 | } 31 | } 32 | 33 | SendPasscode.propTypes = { 34 | children: PropTypes.oneOfType([ 35 | PropTypes.func, 36 | PropTypes.object, 37 | ]).isRequired, 38 | onSendError: PropTypes.func, 39 | onSendSuccess: PropTypes.func, 40 | sendPasscode: PropTypes.func.isRequired, 41 | }; 42 | 43 | SendPasscode.defaultProps = { 44 | onSendError: () => {}, 45 | onSendSuccess: () => {}, 46 | }; 47 | 48 | // Apollo integration 49 | const withMutation = graphql(sendPasscodeMutation, { name: 'sendPasscode' }); 50 | 51 | export default withMutation(SendPasscode); 52 | -------------------------------------------------------------------------------- /client/src/components/common/alert/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | //------------------------------------------------------------------------------ 6 | // STYLES: 7 | //------------------------------------------------------------------------------ 8 | const Div = styled.div` 9 | background-color: ${({ type, theme }) => (type === 'error' && theme.color.dangerLight) 10 | || (type === 'success' && theme.color.successLight) 11 | || 'white'}; 12 | border: 1px solid ${({ type, theme }) => (type === 'error' && theme.color.danger) 13 | || (type === 'success' && theme.color.success) 14 | || 'black'}; 15 | font-size: ${({ theme }) => theme.fontSize.small}; 16 | padding: 10px 15px; 17 | `; 18 | 19 | Div.propTypes = { 20 | type: PropTypes.oneOf(['error', 'success']).isRequired, 21 | }; 22 | //------------------------------------------------------------------------------ 23 | // COMPONENT: 24 | //------------------------------------------------------------------------------ 25 | const Alert = ({ type, content, ...rest }) => ( 26 | content && content.trim().length > 0 27 | ?
{content}
28 | : null 29 | ); 30 | 31 | Alert.propTypes = { 32 | type: PropTypes.oneOf(['error', 'success']).isRequired, 33 | content: PropTypes.string, 34 | }; 35 | 36 | Alert.defaultProps = { 37 | content: '', 38 | }; 39 | //------------------------------------------------------------------------------ 40 | 41 | export default Alert; 42 | -------------------------------------------------------------------------------- /client/src/components/auth/signup-api-call.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import signupMutation from '../../graphql/user/mutation/signup'; 5 | 6 | //------------------------------------------------------------------------------ 7 | // COMPONENT: 8 | //------------------------------------------------------------------------------ 9 | class SignupApiCall extends React.PureComponent { 10 | handleSuccess = async ({ email }) => { 11 | const { onSignupError, onSignupSuccess, signup } = this.props; 12 | 13 | try { 14 | await signup({ variables: { email } }); 15 | onSignupSuccess({ email }); 16 | } catch (exc) { 17 | console.log(exc); 18 | onSignupError(exc); 19 | } 20 | } 21 | 22 | render() { 23 | const { children } = this.props; 24 | 25 | // Public API 26 | const api = { 27 | signupUser: this.handleSuccess, 28 | }; 29 | 30 | return children(api); 31 | } 32 | } 33 | 34 | SignupApiCall.propTypes = { 35 | children: PropTypes.oneOfType([ 36 | PropTypes.func, 37 | PropTypes.object, 38 | ]).isRequired, 39 | onSignupError: PropTypes.func, 40 | onSignupSuccess: PropTypes.func, 41 | signup: PropTypes.func.isRequired, 42 | }; 43 | 44 | SignupApiCall.defaultProps = { 45 | onSignupError: () => {}, 46 | onSignupSuccess: () => {}, 47 | }; 48 | 49 | // Apollo integration 50 | const withMutation = graphql(signupMutation, { name: 'signup' }); 51 | 52 | export default withMutation(SignupApiCall); 53 | -------------------------------------------------------------------------------- /client/src/components/common/loading/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | //------------------------------------------------------------------------------ 5 | // STYLES: 6 | //------------------------------------------------------------------------------ 7 | const Outer = styled.div` 8 | height: 15px; 9 | `; 10 | //------------------------------------------------------------------------------ 11 | const bouncedelay = keyframes` 12 | 0%, 80%, 100% { 13 | transform: scale(0); 14 | } 40% { 15 | transform: scale(1.0); 16 | } 17 | `; 18 | //------------------------------------------------------------------------------ 19 | const Dot = styled.div` 20 | width: 15px; 21 | height: 15px; 22 | background-image: linear-gradient(140deg, rgb(12,6,50) 20%, rgb(66,59,90) 60%, rgb(219,159,159) 100%); 23 | border-radius: 100%; 24 | animation: ${bouncedelay} 1.4s infinite ease-in-out both; 25 | margin-right: 5px; 26 | animation-delay: ${({ delay }) => (delay && 0.16 * delay) || 0}s; 27 | `; 28 | //------------------------------------------------------------------------------ 29 | // COMPONENT: 30 | //------------------------------------------------------------------------------ 31 | const Loading = props => ( 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | 39 | export default Loading; 40 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/mutation/login/index.js: -------------------------------------------------------------------------------- 1 | const { User, validateLogin } = require('../../../../../models'); 2 | 3 | //------------------------------------------------------------------------------ 4 | // MUTATION: 5 | //------------------------------------------------------------------------------ 6 | const login = async (root, args) => { 7 | const { email, passcode } = args; 8 | 9 | const { error } = validateLogin({ email, passcode }); 10 | if (error) { 11 | console.log('INVALID LOGIN CREDENTIALS', error); 12 | throw new Error(error.details[0].message); // Bad request - 400 13 | } 14 | 15 | // Make sure user exists 16 | const user = await User.findByEmail({ email }); 17 | if (!user) { 18 | console.log('USER DOES NOT EXIST'); 19 | throw new Error('Invalid email or passcode'); // Bad request - 400 20 | } 21 | 22 | // Make sure the passcode is valid 23 | const isValidPasscode = await user.validatePasscode({ passcode }); 24 | if (!isValidPasscode) { 25 | console.log('INVALID PASSCODE'); 26 | throw new Error('Invalid email or passcode'); // Bad request - 400 27 | } 28 | 29 | // Check passcode's expiration date 30 | if (user.passcodeExpired()) { 31 | console.log('PASSCODE EXPIRED'); 32 | throw new Error('Passcode has expired'); // Bad request - 400 33 | } 34 | 35 | // Set email to verifield 36 | await user.setEmailToVerified(); 37 | 38 | const token = user.genAuthToken(); 39 | 40 | // Successful request 41 | return { _id: user._id, token }; 42 | }; 43 | 44 | module.exports = login; 45 | -------------------------------------------------------------------------------- /server/src/graphql/subscription/resolvers/mutation/send-push-notification.js: -------------------------------------------------------------------------------- 1 | const pick = require('lodash/pick'); 2 | const { Subscription } = require('../../../../models'); 3 | const pushAPI = require('../../../../services/push'); 4 | const asyncForEach = require('../../../../utils/async-for-each'); 5 | 6 | //------------------------------------------------------------------------------ 7 | // MUTATION: 8 | //------------------------------------------------------------------------------ 9 | /** 10 | * @summary Send push notification to all subscribed users. 11 | */ 12 | const sendPushNotification = async (root, args, ctx) => { 13 | const { title = 'Hey!', body = 'This is a push notification' } = args; // TODO: add (default) icon 14 | const { usr } = ctx; 15 | 16 | // Gather all subscriptions from all subscribed users 17 | // User logged in state validation was moved to Subscription model 18 | const subs = await Subscription.findAll({ user: usr }); 19 | 20 | // Send the messages 21 | asyncForEach(subs, async (sub) => { 22 | try { 23 | await pushAPI.send({ 24 | subscription: pick(sub, ['endpoint', 'keys']), 25 | title, 26 | body, 27 | // icon, 28 | }); 29 | } catch (exc) { 30 | console.log(exc); 31 | // This is probably an old subscription, remove it 32 | await Subscription.deleteByEndpoint({ user: usr, endpoint: sub.endpoint }); 33 | } 34 | }); 35 | 36 | return subs; 37 | }; 38 | //------------------------------------------------------------------------------ 39 | 40 | module.exports = sendPushNotification; 41 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "gcm_sender_id": "100645785961", 5 | "icons": [{ 6 | "src": "img/icons/android-chrome-72x72.png", 7 | "sizes": "72x72", 8 | "type": "image/png" 9 | }, { 10 | "src": "img/icons/icon-android-128x128.png", 11 | "sizes": "128x128", 12 | "type": "image/png" 13 | }, { 14 | "src": "img/icons/icon-android-144x144.png", 15 | "sizes": "144x144", 16 | "type": "image/png" 17 | }, { 18 | "src": "img/icons/icon-android-152x152.png", 19 | "sizes": "152x152", 20 | "type": "image/png" 21 | }, { 22 | "src": "img/icons/icon-android-192x192.png", 23 | "sizes": "192x192", 24 | "type": "image/png" 25 | }, { 26 | "src": "img/icons/icon-android-256x256.png", 27 | "sizes": "256x256", 28 | "type": "image/png" 29 | }, { 30 | "src": "img/icons/icon-android-512x512.png", 31 | "sizes": "512x512", 32 | "type": "image/png" 33 | }], 34 | "start_url": "/?homescreen=true", 35 | "display": "standalone", 36 | "background_color": "#2e2e49", 37 | "theme_color": "#3b395e", 38 | "orientation": "portrait", 39 | "splash_screens": [{ 40 | "src": "img/splash/splash-android-240x320.png", 41 | "sizes": "240x320", 42 | "type": "image/png" 43 | }, { 44 | "src": "img/splash/splash-android-750x1334.png", 45 | "sizes": "750x1334", 46 | "type": "image/png" 47 | }, { 48 | "src": "img/splash/splash-android-1080x1920.png", 49 | "sizes": "1080x1920", 50 | "type": "image/png", 51 | "density": 3 52 | }] 53 | } 54 | 55 | -------------------------------------------------------------------------------- /client/public/manifest.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "gcm_sender_id": "", 5 | "icons": [{ 6 | "src": "img/icons/android-chrome-72x72.png", 7 | "sizes": "72x72", 8 | "type": "image/png" 9 | }, { 10 | "src": "img/icons/icon-android-128x128.png", 11 | "sizes": "128x128", 12 | "type": "image/png" 13 | }, { 14 | "src": "img/icons/icon-android-144x144.png", 15 | "sizes": "144x144", 16 | "type": "image/png" 17 | }, { 18 | "src": "img/icons/icon-android-152x152.png", 19 | "sizes": "152x152", 20 | "type": "image/png" 21 | }, { 22 | "src": "img/icons/icon-android-192x192.png", 23 | "sizes": "192x192", 24 | "type": "image/png" 25 | }, { 26 | "src": "img/icons/icon-android-256x256.png", 27 | "sizes": "256x256", 28 | "type": "image/png" 29 | }, { 30 | "src": "img/icons/icon-android-512x512.png", 31 | "sizes": "512x512", 32 | "type": "image/png" 33 | }], 34 | "start_url": "/?homescreen=true", 35 | "display": "standalone", 36 | "background_color": "#2e2e49", 37 | "theme_color": "#3b395e", 38 | "orientation": "portrait", 39 | "splash_screens": [{ 40 | "src": "img/splash/splash-android-240x320.png", 41 | "sizes": "240x320", 42 | "type": "image/png" 43 | }, { 44 | "src": "img/splash/splash-android-750x1334.png", 45 | "sizes": "750x1334", 46 | "type": "image/png" 47 | }, { 48 | "src": "img/splash/splash-android-1080x1920.png", 49 | "sizes": "1080x1920", 50 | "type": "image/png", 51 | "density": 3 52 | }] 53 | } 54 | -------------------------------------------------------------------------------- /client/src/components/auth/login-api-call/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import loginMutation from '../../../graphql/user/mutation/login'; 5 | 6 | //------------------------------------------------------------------------------ 7 | // COMPONENT: 8 | //------------------------------------------------------------------------------ 9 | class LoginApiCall extends React.PureComponent { 10 | handleSuccess = async ({ passcode }) => { 11 | const { 12 | email, 13 | onLoginError, 14 | onLoginSuccess, 15 | login, 16 | } = this.props; 17 | 18 | try { 19 | const res = await login({ variables: { email, passcode: parseInt(passcode, 10) } }); 20 | onLoginSuccess({ token: res.data.login.token }); 21 | } catch (exc) { 22 | console.log(exc); 23 | onLoginError(exc); 24 | } 25 | } 26 | 27 | render() { 28 | const { children } = this.props; 29 | 30 | // Public API 31 | const api = { 32 | loginUser: this.handleSuccess, 33 | }; 34 | 35 | return children(api); 36 | } 37 | } 38 | 39 | LoginApiCall.propTypes = { 40 | children: PropTypes.oneOfType([ 41 | PropTypes.func, 42 | PropTypes.object, 43 | ]).isRequired, 44 | email: PropTypes.string.isRequired, 45 | onLoginError: PropTypes.func, 46 | onLoginSuccess: PropTypes.func, 47 | login: PropTypes.func.isRequired, 48 | }; 49 | 50 | LoginApiCall.defaultProps = { 51 | onLoginError: () => {}, 52 | onLoginSuccess: () => {}, 53 | }; 54 | 55 | // Apollo integration 56 | const withMutation = graphql(loginMutation, { name: 'login' }); 57 | 58 | export default withMutation(LoginApiCall); 59 | -------------------------------------------------------------------------------- /client/src/components/auth/logout-btn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withApollo } from 'react-apollo'; 4 | import Button from '@material-ui/core/Button'; 5 | import ButtonLink from '../common/button-link'; 6 | 7 | //------------------------------------------------------------------------------ 8 | // COMPONENT: 9 | //------------------------------------------------------------------------------ 10 | const LogoutBtn = ({ 11 | client, 12 | btnType, 13 | disabled, 14 | underline, 15 | onLogoutHook, 16 | }) => { 17 | // Logout user and clear store afterwards 18 | const handleLogout = (evt) => { 19 | if (evt) { evt.preventDefault(); } 20 | // Remove auth token from localStorage 21 | localStorage.removeItem('x-auth-token'); 22 | // Clear apollo store 23 | client.resetStore(); 24 | // Pass event up to parent component 25 | onLogoutHook(); 26 | }; 27 | 28 | const ButtonComp = btnType === 'link' ? ButtonLink : Button; 29 | 30 | return ( 31 | 36 | Log out 37 | 38 | ); 39 | }; 40 | 41 | LogoutBtn.propTypes = { 42 | client: PropTypes.shape({ 43 | resetStore: PropTypes.func.isRequired, 44 | }).isRequired, 45 | btnType: PropTypes.oneOf(['button', 'link']), 46 | disabled: PropTypes.bool, 47 | underline: PropTypes.oneOf(['underline', 'none']), 48 | onLogoutHook: PropTypes.func, 49 | }; 50 | 51 | LogoutBtn.defaultProps = { 52 | btnType: 'button', 53 | disabled: false, 54 | underline: 'underline', 55 | onLogoutHook: () => {}, 56 | }; 57 | 58 | export default withApollo(LogoutBtn); 59 | -------------------------------------------------------------------------------- /server/.sample.env: -------------------------------------------------------------------------------- 1 | # This file contains all ENV vars necessary to run the app LOCALLY for both 2 | # development and production modes. 3 | # 4 | # IMPORTANT: DO NOT store sensitive data here, this file may be committed to 5 | # version control! 6 | # 7 | # In case of production or staging deployments (apps hosted on heroku), use the 8 | # 'heroku config:set' command (heroku command line tool) to set your environment 9 | # variables. For instance, let's suppose we want to set MONGO_URL for our 10 | # production deployment: 11 | # 1. Install the heroku command line tool: https://devcenter.heroku.com/articles/heroku-cli 12 | # 2. Open a new terminal and type: 'heroku login'. Enter your credentials. 13 | # 3. Set your MONGO_URL env var: 14 | # heroku config:set MONGO_URL=mongodb://:@ds129459.mlab.com:/ 15 | 16 | 17 | 18 | # Example of MONGO_URL env var for local apps connected to mLab MongoDB instance: 19 | # MONGO_URL=mongodb://admin:123456@ds129459.mlab.com:29459/dbtest 20 | 21 | # Example of MONGO_URL env var for local apps connected local MongoDB instance: 22 | MONGO_URL=mongodb://localhost:27017/crae-apollo 23 | MONGO_URL_TEST=mongodb://localhost:27017/crae-apollo-test 24 | # crae-apollo is the name we give to our mongoDB instance. Feel free to change it! 25 | JWT_PRIVATE_KEY=xxx # just a random string (pick any) to generate the json token 26 | # Regarding Mailgun encryption see: https://www.mailgun.com/blog/outgoing-message-security-settings-now-available-in-the-control-panel 27 | SMTP_HOST=smtp.mailgun.org 28 | SMTP_USERNAME=MAILGUN_USERNAME.mailgun.org 29 | SMTP_PASSWORD=MAILGUN_PASSWORD 30 | SMTP_PORT=587 31 | GCM_PRIVATE_KEY=xxx 32 | VAPID_SUBJECT=mailto:xxx@example.com 33 | VAPID_PUBLIC_KEY=xxx 34 | VAPID_PRIVATE_KEY=xxx -------------------------------------------------------------------------------- /server/src/startup/env-vars.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | /** 4 | * @summary Makes sure all env vars are set 5 | */ 6 | 7 | const { 8 | // NODE_ENV, 9 | MONGO_URL, 10 | // MONGO_URL_TEST, 11 | JWT_PRIVATE_KEY, 12 | SMTP_HOST, 13 | SMTP_USERNAME, 14 | SMTP_PASSWORD, 15 | SMTP_PORT, 16 | GCM_PRIVATE_KEY, 17 | VAPID_SUBJECT, 18 | VAPID_PUBLIC_KEY, 19 | VAPID_PRIVATE_KEY, 20 | } = process.env; 21 | 22 | // if (NODE_ENV && NODE_ENV === 'test' && (!MONGO_URL_TEST || MONGO_URL_TEST.trim().length === 0)) { 23 | // console.error('FATAL ERROR: MONGO_URL_TEST env var missing'); 24 | // process.exit(1); 25 | // } 26 | 27 | if (!MONGO_URL || MONGO_URL.trim().length === 0) { 28 | console.error('FATAL ERROR: MONGO_URL env var missing'); 29 | process.exit(1); 30 | } 31 | 32 | if (!JWT_PRIVATE_KEY || JWT_PRIVATE_KEY.trim().length === 0) { 33 | console.error('FATAL ERROR: JWT_PRIVATE_KEY env var missing'); 34 | process.exit(1); 35 | } 36 | 37 | if ( 38 | !SMTP_HOST || SMTP_HOST.trim().length === 0 39 | || !SMTP_USERNAME || SMTP_USERNAME.trim().length === 0 40 | || !SMTP_PASSWORD || SMTP_PASSWORD.trim().length === 0 41 | || !SMTP_PORT || SMTP_PORT.trim().length === 0 42 | ) { 43 | console.error('FATAL ERROR: SMTP env vars missing'); 44 | process.exit(1); 45 | } 46 | 47 | if (!GCM_PRIVATE_KEY || GCM_PRIVATE_KEY.trim().length === 0) { 48 | console.error('FATAL ERROR: GCM_PRIVATE_KEY env var missing'); 49 | process.exit(1); 50 | } 51 | 52 | if ( 53 | !VAPID_SUBJECT || VAPID_SUBJECT.trim().length === 0 54 | || !VAPID_PUBLIC_KEY || VAPID_PUBLIC_KEY.trim().length === 0 55 | || !VAPID_PRIVATE_KEY || VAPID_PRIVATE_KEY.trim().length === 0 56 | ) { 57 | console.error('FATAL ERROR: VAPID envs var missing'); 58 | process.exit(1); 59 | } 60 | -------------------------------------------------------------------------------- /client/src/global-data-provider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import { propType } from 'graphql-anywhere'; 5 | import userFragment from './graphql/user/fragment/user'; 6 | import userQuery from './graphql/user/query/user'; 7 | 8 | const Context = React.createContext(); 9 | 10 | //------------------------------------------------------------------------------ 11 | // COMPONENT: 12 | //------------------------------------------------------------------------------ 13 | /** 14 | * @summary Injects global data (current user, global settings, whatever) into 15 | * app's context. 16 | */ 17 | const GlobalDataProvider = ({ userData, children }) => { 18 | const { error, loading, user } = userData; 19 | 20 | if (loading) { 21 | return

Loading ...

; 22 | } 23 | if (error) { 24 | return

{error.message}

; 25 | } 26 | 27 | return ( 28 | 31 | {children} 32 | 33 | ); 34 | }; 35 | 36 | GlobalDataProvider.propTypes = { 37 | userData: PropTypes.shape({ 38 | error: PropTypes.object, 39 | loading: PropTypes.bool.isRequired, 40 | user: propType(userFragment), 41 | refetch: PropTypes.func.isRequired, 42 | }).isRequired, 43 | children: PropTypes.oneOfType([ 44 | PropTypes.func, 45 | PropTypes.object, 46 | ]).isRequired, 47 | }; 48 | 49 | // Apollo integration 50 | const withData = graphql(userQuery, { name: 'userData' }); 51 | 52 | export default withData(GlobalDataProvider); 53 | 54 | export const withUser = Component => props => ( 55 | 56 | {userProps => } 57 | 58 | ); 59 | -------------------------------------------------------------------------------- /client/src/components/common/button-link/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | //------------------------------------------------------------------------------ 6 | // STYLES: 7 | //------------------------------------------------------------------------------ 8 | const Button = styled.button` 9 | /* reset button style */ 10 | background: none !important; 11 | border: none; 12 | padding: 0 !important; 13 | font: 'inherit'; 14 | text-decoration: ${({ disabled, underline }) => ( 15 | (!disabled || disabled === false) ? underline : 'none' 16 | )}; 17 | color: ${({ disabled, theme }) => ( 18 | (!disabled || disabled === false) ? theme.color.link : 'inherit' 19 | )}; 20 | cursor: ${({ disabled }) => ( 21 | (!disabled || disabled === false) ? 'pointer' : 'not-allowed' 22 | )}; 23 | `; 24 | 25 | Button.propTypes = { 26 | type: PropTypes.oneOf(['button']).isRequired, 27 | underline: PropTypes.oneOf(['underline', 'none']).isRequired, 28 | }; 29 | 30 | //------------------------------------------------------------------------------ 31 | // COMPONENT: 32 | //------------------------------------------------------------------------------ 33 | const ButtonLink = ({ children, underline, ...rest }) => ( 34 | 41 | ); 42 | 43 | ButtonLink.propTypes = { 44 | children: PropTypes.oneOfType([ 45 | PropTypes.object, 46 | PropTypes.string, 47 | ]).isRequired, 48 | underline: PropTypes.oneOf(['underline', 'none']), 49 | // Plus all of the native button props 50 | }; 51 | 52 | ButtonLink.defaultProps = { 53 | underline: 'underline', 54 | }; 55 | 56 | export default ButtonLink; 57 | -------------------------------------------------------------------------------- /client/src/render-props/message-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | //------------------------------------------------------------------------------ 5 | // PROPS AND METHODS PROVIDER: 6 | //------------------------------------------------------------------------------ 7 | class MessageProps extends React.PureComponent { 8 | state = { 9 | errorMsg: '', 10 | successMsg: '', 11 | } 12 | 13 | setErrorMessage = (msg) => { 14 | this.setState(() => ({ errorMsg: msg })); 15 | } 16 | 17 | setSuccessMessage = (msg) => { 18 | this.setState(() => ({ successMsg: msg })); 19 | } 20 | 21 | clearMessages = () => { 22 | this.setState(() => ({ errorMsg: '', successMsg: '' })); 23 | } 24 | 25 | render() { 26 | const { children } = this.props; 27 | const { errorMsg, successMsg } = this.state; 28 | 29 | // Public API 30 | const api = { 31 | errorMsg, 32 | successMsg, 33 | setErrorMessage: this.setErrorMessage, 34 | setSuccessMessage: this.setSuccessMessage, 35 | clearMessages: this.clearMessages, 36 | }; 37 | 38 | return children(api); 39 | } 40 | } 41 | 42 | MessageProps.propTypes = { 43 | children: PropTypes.oneOfType([ 44 | PropTypes.func, 45 | PropTypes.object, 46 | ]).isRequired, 47 | }; 48 | 49 | export default MessageProps; 50 | 51 | //------------------------------------------------------------------------------ 52 | // PROP TYPES: 53 | //------------------------------------------------------------------------------ 54 | export const messagePropTypes = { 55 | errorMsg: PropTypes.string.isRequired, 56 | successMsg: PropTypes.string.isRequired, 57 | setErrorMessage: PropTypes.func.isRequired, 58 | setSuccessMessage: PropTypes.func.isRequired, 59 | clearMessages: PropTypes.func.isRequired, 60 | }; 61 | -------------------------------------------------------------------------------- /server/src/graphql/user/resolvers/mutation/send-passcode.js: -------------------------------------------------------------------------------- 1 | const { nodemailer, transporter } = require('../../../../services/nodemailer/config'); 2 | const { User } = require('../../../../models'); 3 | 4 | //------------------------------------------------------------------------------ 5 | // AUX FUNCTIONS: 6 | //------------------------------------------------------------------------------ 7 | const getText = ({ passcode }) => (` 8 | Hello, 9 | 10 | Your verification code is ${passcode}. 11 | 12 | Thanks. 13 | `); 14 | //------------------------------------------------------------------------------ 15 | // MUTATION: 16 | //------------------------------------------------------------------------------ 17 | const sendPasscode = async (root, args) => { 18 | const { email } = args; 19 | 20 | // Is there any user associated to this email? 21 | const user = await User.findByEmail({ email }); 22 | if (!user) { 23 | throw new Error('User is not registered'); // Bad request - 400 24 | } 25 | 26 | // Genearte a 6-digit pass code and attach it to the user 27 | const passcode = await user.genPasscode(6); 28 | 29 | // Send pass code to user 30 | const mailOptions = { 31 | from: 'email@example.com', // sender address 32 | to: email, // list of receivers 33 | subject: `Your pass code is ${passcode} for `, // subject line 34 | text: getText({ passcode }), // plain text body 35 | // html: 'Hello world?', // html body 36 | }; 37 | 38 | // Send email with defined transport object 39 | const info = await transporter.sendMail(mailOptions); 40 | console.log('Message sent: %s', info.messageId); 41 | // Preview only available when sending through an Ethereal account 42 | console.log('Preview URL: %s', nodemailer.getTestMessageUrl(info)); 43 | return user; 44 | }; 45 | 46 | module.exports = sendPasscode; 47 | -------------------------------------------------------------------------------- /client/src/app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ApolloProvider } from 'react-apollo'; 4 | import { Router } from 'react-router-dom'; 5 | import createBrowserHistory from 'history/createBrowserHistory'; 6 | import { ThemeProvider } from 'styled-components'; 7 | import { MuiThemeProvider } from '@material-ui/core/styles'; 8 | import client from '../graphql/apollo-client'; 9 | import scTheme from '../theme/sc'; 10 | import muiTheme from '../theme/mui'; 11 | import GlobalDataProvider from '../global-data-provider'; 12 | 13 | // Given that we are implementing App Shell Architecture and, therefore, 14 | // injecting (via reactDOM.render) the Header and Main components into 15 | // different HTML elements, we need a way to share the router 'history' among 16 | // all two mentioned components. 17 | // As a default, for every invocation of 'BrowserRouter', there will be new 18 | // 'history' instance created. Then, changes in the 'history' object in one 19 | // component won't be available in the other components. To prevent this, we are 20 | // relying on 'Router' component instead of 'BrowserRouter' and defining our 21 | // custom 'history' object by means of 'createBrowserHistory' function. Said 22 | // 'history' object is then passed to every invocation of 'Router' and therefore 23 | // the same 'history' object will be shared among all three mentioned components. 24 | const history = createBrowserHistory(); 25 | 26 | const App = ({ component: Component }) => ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | App.propTypes = { 41 | component: PropTypes.func.isRequired, 42 | }; 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /client/src/components/pwa/push-btn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import Button from '@material-ui/core/Button'; 5 | import sendPushNotificationMutation from '../../graphql/subscription/mutation/send-push-notification'; 6 | 7 | //------------------------------------------------------------------------------ 8 | // COMPONENT: 9 | //------------------------------------------------------------------------------ 10 | class PushBtn extends React.PureComponent { 11 | handleClick = async () => { 12 | const { 13 | sendPushNotification, 14 | onBeforeHook, 15 | onClientCancelHook, 16 | onServerErrorHook, 17 | onSuccessHook, 18 | } = this.props; 19 | 20 | // Run before logic if provided and return on error 21 | try { 22 | onBeforeHook(); 23 | } catch (exc) { 24 | onClientCancelHook(); 25 | return; // return silently 26 | } 27 | 28 | try { 29 | await sendPushNotification(); 30 | onSuccessHook(); 31 | } catch (exc) { 32 | onServerErrorHook(exc); 33 | } 34 | } 35 | 36 | render() { 37 | const { btnLabel, disabled } = this.props; 38 | 39 | return ( 40 | 48 | ); 49 | } 50 | } 51 | 52 | PushBtn.propTypes = { 53 | btnLabel: PropTypes.string, 54 | disabled: PropTypes.bool, 55 | sendPushNotification: PropTypes.func.isRequired, 56 | onBeforeHook: PropTypes.func, 57 | onClientCancelHook: PropTypes.func, 58 | onServerErrorHook: PropTypes.func, 59 | onSuccessHook: PropTypes.func, 60 | }; 61 | 62 | PushBtn.defaultProps = { 63 | btnLabel: 'Send Push Notification', 64 | disabled: false, 65 | onBeforeHook: () => {}, 66 | onClientCancelHook: () => {}, 67 | onServerErrorHook: () => {}, 68 | onSuccessHook: () => {}, 69 | }; 70 | 71 | // Apollo integration 72 | const withMutation = graphql(sendPushNotificationMutation, { name: 'sendPushNotification' }); 73 | 74 | export default withMutation(PushBtn); 75 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 |
51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /client/src/render-props/form-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import DisabledProps, { disabledPropTypes } from './disabled-props'; 4 | import MessageProps, { messagePropTypes } from './message-props'; 5 | import HookProps, { hookPropTypes } from './hook-props'; 6 | 7 | //------------------------------------------------------------------------------ 8 | // PROPS AND METHODS PROVIDER: 9 | //------------------------------------------------------------------------------ 10 | const FormProps = ({ children }) => ( 11 | 12 | {disabledProps => ( 13 | 14 | {messageProps => ( 15 | 19 | {(hookProps) => { 20 | // Public API 21 | const api = { 22 | disabled: disabledProps.disabled, 23 | errorMsg: messageProps.errorMsg, 24 | successMsg: messageProps.successMsg, 25 | setSuccessMessage: messageProps.setSuccessMessage, 26 | clearMessages: messageProps.clearMessages, 27 | handleBefore: hookProps.handleBefore, 28 | handleClientCancel: hookProps.handleClientCancel, 29 | handleClientError: hookProps.handleClientError, 30 | handleServerError: hookProps.handleServerError, 31 | handleSuccess: hookProps.handleSuccess, 32 | }; 33 | 34 | return children(api); 35 | }} 36 | 37 | )} 38 | 39 | )} 40 | 41 | ); 42 | 43 | FormProps.propTypes = { 44 | children: PropTypes.oneOfType([ 45 | PropTypes.func, 46 | PropTypes.object, 47 | ]).isRequired, 48 | }; 49 | 50 | export default FormProps; 51 | 52 | //------------------------------------------------------------------------------ 53 | // PROP TYPES: 54 | //------------------------------------------------------------------------------ 55 | export const formPropTypes = { 56 | disabled: disabledPropTypes.disabled, 57 | errorMsg: messagePropTypes.errorMsg, 58 | successMsg: messagePropTypes.successMsg, 59 | setSuccessMessage: messagePropTypes.setSuccessMessage, 60 | clearMessages: messagePropTypes.clearMessages, 61 | ...hookPropTypes, 62 | }; 63 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "proxy": "http://localhost:3001", 6 | "scripts": { 7 | "start": "react-scripts start", 8 | "build": "react-scripts build && sw-precache --config=sw-precache-config.js", 9 | "test": "react-scripts test", 10 | "test-cov": "react-scripts test --coverage", 11 | "eject": "react-scripts eject", 12 | "lint": "eslint .", 13 | "storybook": "start-storybook -p 9009 -s public", 14 | "build-storybook": "build-storybook -s public", 15 | "get-graphql-schema": "get-graphql-schema http://localhost:3001/graphql > schema.json --json && mv schema.json ./src/graphql/schema.json" 16 | }, 17 | "dependencies": { 18 | "@material-ui/core": "^3.6.0", 19 | "@material-ui/icons": "^3.0.1", 20 | "apollo-cache-inmemory": "^1.3.11", 21 | "apollo-client": "^2.4.7", 22 | "apollo-link-context": "^1.0.10", 23 | "apollo-link-http": "^1.5.7", 24 | "apollo-link-schema": "^1.1.4", 25 | "error-handling-utils": "^1.1.0", 26 | "graphql": "^14.0.2", 27 | "graphql-anywhere": "^4.1.23", 28 | "graphql-tag": "^2.10.0", 29 | "graphql-tools": "^4.0.3", 30 | "history": "^4.7.2", 31 | "prop-types": "^15.6.2", 32 | "react": "^16.6.3", 33 | "react-apollo": "^2.3.1", 34 | "react-app-polyfill": "^0.2.0", 35 | "react-dom": "^16.6.3", 36 | "react-router-dom": "^4.3.1", 37 | "react-scripts": "2.1.2", 38 | "styled-components": "^4.1.1", 39 | "unfetch": "^4.0.1", 40 | "validator": "^10.9.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.2.0", 44 | "@storybook/addon-actions": "^4.0.12", 45 | "@storybook/addon-links": "^4.0.12", 46 | "@storybook/addon-storyshots": "^4.1.4", 47 | "@storybook/addons": "^4.0.12", 48 | "@storybook/react": "^4.0.12", 49 | "babel-loader": "^8.0.4", 50 | "enzyme": "^3.7.0", 51 | "enzyme-adapter-react-16": "^1.7.0", 52 | "enzyme-to-json": "^3.3.4", 53 | "eslint": "^5.9.0", 54 | "eslint-config-airbnb": "17.1.0", 55 | "eslint-plugin-import": "^2.14.0", 56 | "eslint-plugin-jsx-a11y": "^6.1.2", 57 | "eslint-plugin-react": "^7.11.1", 58 | "get-graphql-schema": "^2.1.2", 59 | "jest-dom": "^3.0.0", 60 | "react-testing-library": "^5.4.2", 61 | "require-context.macro": "^1.0.4", 62 | "sw-precache": "^5.2.1", 63 | "waait": "^1.0.2" 64 | }, 65 | "browserslist": [ 66 | ">0.2%", 67 | "not dead", 68 | "not ie <= 11", 69 | "not op_mini all" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /client/public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/public/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | # BEM (BLOCK, ELEMENT, MODIFIER) METHEDOLOGY 3 |
4 |
5 |
6 |
7 |
8 | .card - BLOCK 9 | .card__title - ELEMENT 10 | .card--show - MODIFIER 11 | */ 12 | 13 | 14 | /* RESET styles */ 15 | 16 | a { 17 | text-decoration: none; 18 | color: inherit; 19 | } 20 | 21 | a:hover { 22 | text-decoration: underline; 23 | } 24 | 25 | ul, 26 | li { 27 | list-style: none; 28 | padding: 0; 29 | margin: 0; 30 | } 31 | 32 | .no--select { 33 | -moz-user-select: none; 34 | -ms-user-select: none; 35 | -webkit-user-select: none; 36 | user-select: none; 37 | } 38 | 39 | /* MAIN styles */ 40 | 41 | body { 42 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 43 | font-size: 16px; 44 | -webkit-font-smoothing: antialiased; 45 | -webkit-text-size-adjust: 100%; 46 | padding: 0; 47 | margin: 0; 48 | min-height: 100vh; 49 | scroll-behavior: smooth; 50 | } 51 | 52 | a { 53 | color: blue; 54 | } 55 | 56 | header { 57 | width: 100%; 58 | height: 56px; 59 | background-color: blue; 60 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.19); 61 | overflow: hidden; 62 | color: #fff; 63 | -webkit-user-select: none; 64 | -moz-user-select: none; 65 | -ms-user-select: none; 66 | user-select: none; 67 | -webkit-transition: background-color 250ms linear; 68 | transition: background-color 250ms linear; 69 | } 70 | 71 | form { 72 | width: 100%; 73 | } 74 | 75 | .app__offline { 76 | background-color: #6b6b6b; 77 | } 78 | 79 | .header__btn { 80 | width: 48px; 81 | height: 48px; 82 | cursor: pointer; 83 | visibility: hidden; 84 | opacity: 0; 85 | } 86 | 87 | .header__title { 88 | color: #fff; 89 | font-size: 20px; 90 | } 91 | 92 | .app__outer { 93 | min-height: calc(100vh - 56px); /* do not consider header height */ 94 | } 95 | 96 | .app__container { 97 | max-width: 1200px; 98 | min-height: 420px; 99 | width: 100%; 100 | } 101 | 102 | .toast__container { 103 | bottom: 20px; 104 | left: 20px; 105 | pointer-events: none; 106 | } 107 | 108 | .toast__msg { 109 | width: 250px; 110 | min-height: 50px; 111 | background: rgba(0, 0, 0, 0.9); 112 | color: #fff; 113 | font-size: 14px; 114 | font-weight: 500; 115 | padding-left: 15px; 116 | padding-right: 10px; 117 | word-break: break-all; 118 | -webkit-transition: opacity 3s cubic-bezier(0, 0, 0.30, 1) 0; 119 | -webkit-transition: opacity 0.30s cubic-bezier(0, 0, 0.30, 1) 0; 120 | transition: opacity 0.30s cubic-bezier(0, 0, 0.30, 1) 0; 121 | text-transform: initial; 122 | margin-bottom: 10px; 123 | border-radius: 2px; 124 | } 125 | 126 | .toast__msg--hide { 127 | opacity: 0; 128 | } 129 | 130 | .pointer { 131 | cursor: pointer; 132 | } -------------------------------------------------------------------------------- /client/src/components/auth/resend-passcode-btn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ButtonLink from '../common/button-link'; 4 | import SendPasscode from './send-passcode'; 5 | 6 | //------------------------------------------------------------------------------ 7 | // COMPONENT: 8 | //------------------------------------------------------------------------------ 9 | class Button extends React.PureComponent { 10 | handleClick = async () => { 11 | const { onBeforeHook, onClientCancelHook, onClick } = this.props; 12 | 13 | // Run before logic if provided and return on error 14 | try { 15 | onBeforeHook(); 16 | } catch (exc) { 17 | onClientCancelHook(); 18 | return; // return silently 19 | } 20 | 21 | // Pass event up to parent component 22 | onClick(); 23 | } 24 | 25 | render() { 26 | const { label, disabled } = this.props; 27 | 28 | return ( 29 | 33 | {label} 34 | 35 | ); 36 | } 37 | } 38 | 39 | Button.propTypes = { 40 | label: PropTypes.string.isRequired, 41 | disabled: PropTypes.bool, 42 | onBeforeHook: PropTypes.func, 43 | onClientCancelHook: PropTypes.func, 44 | onClick: PropTypes.func, 45 | }; 46 | 47 | Button.defaultProps = { 48 | disabled: false, 49 | onBeforeHook: () => {}, 50 | onClientCancelHook: () => {}, 51 | onClick: () => {}, 52 | }; 53 | //------------------------------------------------------------------------------ 54 | // COMPONENT 55 | //------------------------------------------------------------------------------ 56 | 57 | const ResendPasscodeBtn = ({ 58 | email, 59 | label, 60 | disabled, 61 | onBeforeHook, 62 | onClientCancelHook, 63 | onSendError, 64 | onSendSuccess, 65 | }) => ( 66 | 70 | {({ sendPasscode }) => ( 71 | 30 | `; 31 | 32 | exports[`Storyshots ButtonLink ButtonLink disabled 1`] = ` 33 | 40 | `; 41 | 42 | exports[`Storyshots ButtonLink ButtonLink no underline 1`] = ` 43 | 49 | `; 50 | 51 | exports[`Storyshots ButtonLink ButtonLink no underline disabled 1`] = ` 52 | 59 | `; 60 | 61 | exports[`Storyshots Divider Divider 1`] = ` 62 |
65 | `; 66 | 67 | exports[`Storyshots Feedback Error 1`] = ` 68 |
71 |
75 | I'm an error msg 76 |
77 |
78 | `; 79 | 80 | exports[`Storyshots Feedback Loading 1`] = ` 81 |
84 |
87 |
90 |
93 |
96 |
97 |
98 | `; 99 | 100 | exports[`Storyshots Feedback Success 1`] = ` 101 |
104 |
108 | I'm a success msg 109 |
110 |
111 | `; 112 | 113 | exports[`Storyshots Loading Loading 1`] = ` 114 |
117 |
120 |
123 |
126 |
127 | `; 128 | 129 | exports[`Storyshots Subtitle Subtitle 1`] = ` 130 |

133 | 134 | I'm the Subtitle  135 | 136 | 137 |

138 | `; 139 | 140 | exports[`Storyshots Title Title 1`] = ` 141 |

144 | I'm the Title 145 |

146 | `; 147 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const helmet = require('helmet'); 3 | const morgan = require('morgan'); 4 | const path = require('path'); 5 | const cors = require('cors'); 6 | 7 | //------------------------------------------------------------------------------ 8 | // MAKE SURE ENV VARS ARE SET 9 | //------------------------------------------------------------------------------ 10 | require('./src/startup/env-vars'); 11 | 12 | //------------------------------------------------------------------------------ 13 | // CONFIG VALIDATION LIBS 14 | //------------------------------------------------------------------------------ 15 | require('./src/startup/validation'); 16 | 17 | //------------------------------------------------------------------------------ 18 | // CONFIG LOGGER & CATCH UNCAUGHT EXCEPTIONS 19 | //------------------------------------------------------------------------------ 20 | require('./src/startup/logger'); 21 | 22 | //------------------------------------------------------------------------------ 23 | // INIT EXPRESS SERVER 24 | //------------------------------------------------------------------------------ 25 | // Initialize Express server. Port is set by Heroku when the app is deployed or 26 | // when running locally using the 'heroku local' command. 27 | const { PORT } = process.env; 28 | console.log('\nprocess.env.PORT', PORT); 29 | 30 | const app = express(); 31 | app.set('port', (PORT || 3001)); 32 | 33 | //------------------------------------------------------------------------------ 34 | // MIDDLEWARES 35 | //------------------------------------------------------------------------------ 36 | // Apply middleware to parse incoming body requests into JSON format. 37 | app.use(helmet()); 38 | app.use(express.urlencoded({ extended: true })); 39 | app.use(express.json()); 40 | 41 | if (app.get('env') === 'development') { 42 | // Enable the app to receive requests from the React app and Storybook when running locally. 43 | app.use('*', cors({ origin: ['http://localhost:3000', 'http://localhost:9009'] })); 44 | app.use(morgan('tiny')); 45 | } 46 | 47 | //------------------------------------------------------------------------------ 48 | // MONGO CONNECTION 49 | //------------------------------------------------------------------------------ 50 | require('./src/startup/db'); 51 | 52 | //------------------------------------------------------------------------------ 53 | // SERVER STATIC FILE 54 | //------------------------------------------------------------------------------ 55 | // Serve static files from the React app 56 | const staticFiles = express.static(path.join(__dirname, '../../client/build')); 57 | app.use(staticFiles); 58 | 59 | //------------------------------------------------------------------------------ 60 | // APOLLO SERVER 61 | //------------------------------------------------------------------------------ 62 | require('./src/startup/apollo-server')(app); 63 | 64 | //------------------------------------------------------------------------------ 65 | // ERROR HANDLING MIDDLEWARE 66 | //------------------------------------------------------------------------------ 67 | app.use(require('./src/middlewares/error')); 68 | 69 | //------------------------------------------------------------------------------ 70 | // CATCH ALL 71 | //------------------------------------------------------------------------------ 72 | // The "catchall" handler: for any request that doesn't match one above, send 73 | // back React's index.html file. 74 | app.use('*', staticFiles); 75 | 76 | //------------------------------------------------------------------------------ 77 | // LISTEN 78 | //------------------------------------------------------------------------------ 79 | app.listen(app.get('port'), () => { 80 | console.log(`Apollo server listening on http://localhost:${app.get('port')}/graphql`); 81 | }); 82 | -------------------------------------------------------------------------------- /client/src/components/auth/login-api-call/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { mount } from 'enzyme'; 4 | import { ApolloProvider } from 'react-apollo'; 5 | import wait from 'waait'; 6 | import mockClient from '../../../graphql/apollo-mock-client'; 7 | import LoginApiCall from '.'; 8 | 9 | // TODO: before all to mount/shallow component 10 | 11 | describe('LoginApiCall', () => { 12 | it('renders without crashing', () => { 13 | const div = document.createElement('div'); 14 | 15 | ReactDOM.render( 16 | 17 | 18 | {({ loginUser }) =>
} 19 | 20 | , 21 | div, 22 | ); 23 | }); 24 | 25 | it('calls login mutation passing email and passcode', async () => { 26 | const spy = jest.spyOn(mockClient, 'mutate'); 27 | 28 | const wrapper = mount( 29 | 30 | 31 | {({ loginUser }) => ( 32 | 38 | )} 39 | 40 | , 41 | ); 42 | 43 | wrapper.find('button').simulate('click'); 44 | await wait(0); 45 | 46 | expect(spy).toBeCalled(); 47 | expect(spy.mock.calls[0][0].variables).toMatchObject({ 48 | email: 'email@example.com', 49 | passcode: parseInt('123456', 10), 50 | }); 51 | spy.mockRestore(); 52 | }); 53 | 54 | it('calls onLoginSuccess when passing valid email and passcode', async () => { 55 | const handleLoginSuccess = jest.fn(); 56 | const handleLoginError = jest.fn(); 57 | 58 | const wrapper = mount( 59 | 60 | 65 | {({ loginUser }) => ( 66 | 72 | )} 73 | 74 | , 75 | ); 76 | 77 | wrapper.find('button').simulate('click'); 78 | await wait(0); 79 | 80 | expect(handleLoginSuccess).toBeCalled(); 81 | expect(handleLoginSuccess).toBeCalledWith( 82 | expect.objectContaining({ token: 'xyz123' }), 83 | ); 84 | expect(handleLoginError).not.toBeCalled(); 85 | }); 86 | 87 | it('calls onLoginError when passing invalid email and/or passcode', async () => { 88 | const handleLoginSuccess = jest.fn(); 89 | const handleLoginError = jest.fn(); 90 | 91 | const wrapper = mount( 92 | 93 | 98 | {({ loginUser }) => ( 99 | 105 | )} 106 | 107 | , 108 | ); 109 | 110 | wrapper.find('button').simulate('click'); 111 | await wait(0); 112 | 113 | expect(handleLoginSuccess).not.toBeCalled(); 114 | expect(handleLoginError).toBeCalled(); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /client/src/components/pwa/unsubscribe-btn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import Button from '@material-ui/core/Button'; 5 | import userQuery from '../../graphql/user/query/user'; 6 | import deleteSubscriptionMutation from '../../graphql/subscription/mutation/delete-subscription'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // COMPONENT: 10 | //------------------------------------------------------------------------------ 11 | // Source: https://github.com/GoogleChrome/samples/blob/gh-pages/push-messaging-and-notifications/main.js 12 | class UnsubscribeBtn extends React.PureComponent { 13 | handleClick = async () => { 14 | const { 15 | deleteSubscription, 16 | onBeforeHook, 17 | onClientCancelHook, 18 | onClientErrorHook, 19 | onServerErrorHook, 20 | onSuccessHook, 21 | } = this.props; 22 | 23 | // Run before logic if provided and return on error 24 | try { 25 | onBeforeHook(); 26 | } catch (exc) { 27 | onClientCancelHook(); 28 | return; // return silently 29 | } 30 | 31 | let subscription = null; 32 | 33 | try { 34 | // We need the service worker registration to check for a subscription 35 | const registration = await navigator.serviceWorker.ready; 36 | 37 | // To unsubscribe from push messaging, you need to get the subcription 38 | // object, which you can call unsubscribe() on 39 | subscription = await registration.pushManager.getSubscription(); 40 | 41 | // Check we have a subscription to unsubscribe 42 | if (!subscription) { 43 | // No subscription object, so set the state to allow the user to 44 | // subscribe to push 45 | onSuccessHook(); 46 | } 47 | 48 | // We have a subcription, so call unsubscribe on it 49 | await subscription.unsubscribe(); 50 | } catch (exc) { 51 | // We failed to unsubscribe, this can lead to an unusual state, so may be 52 | // best to remove the subscription from your data store and inform the 53 | // user that you disabled push 54 | onClientErrorHook(`Error thrown while unsubscribing from push messaging: ${exc}`); 55 | } 56 | 57 | try { 58 | // Get subscription enpoint to be able to find the subscription server side 59 | const { endpoint } = subscription; 60 | 61 | // Delete subscription from user's record 62 | await deleteSubscription({ 63 | variables: { endpoint }, 64 | refetchQueries: [{ query: userQuery }], 65 | }); 66 | 67 | // QUESTION: shouldn't we make a request to your server to remove all user 68 | // subscriptions from our data store so we don't attempt to send them push 69 | // messages anymore? 70 | 71 | onSuccessHook(); 72 | } catch (exc) { 73 | onServerErrorHook(exc); 74 | } 75 | } 76 | 77 | render() { 78 | const { btnLabel, disabled } = this.props; 79 | 80 | return ( 81 | 89 | ); 90 | } 91 | } 92 | 93 | UnsubscribeBtn.propTypes = { 94 | btnLabel: PropTypes.string, 95 | disabled: PropTypes.bool, 96 | deleteSubscription: PropTypes.func.isRequired, 97 | onBeforeHook: PropTypes.func, 98 | onClientCancelHook: PropTypes.func, 99 | onServerErrorHook: PropTypes.func, 100 | onSuccessHook: PropTypes.func, 101 | }; 102 | 103 | UnsubscribeBtn.defaultProps = { 104 | btnLabel: 'Disable Push Messages', 105 | disabled: false, 106 | onBeforeHook: () => {}, 107 | onClientCancelHook: () => {}, 108 | onServerErrorHook: () => {}, 109 | onSuccessHook: () => {}, 110 | }; 111 | 112 | const withMutation = graphql(deleteSubscriptionMutation, { name: 'deleteSubscription' }); 113 | 114 | export default withMutation(UnsubscribeBtn); 115 | -------------------------------------------------------------------------------- /server/src/models/subscription/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | const mongoose = require('mongoose'); 3 | const Joi = require('joi'); 4 | 5 | //------------------------------------------------------------------------------ 6 | // CONSTANTS: 7 | //------------------------------------------------------------------------------ 8 | const MIN_STRING_LENGTH = 2; 9 | const MAX_STRING_LENGTH = 155; 10 | //------------------------------------------------------------------------------ 11 | // MONGOOSE SCHEMA: 12 | //------------------------------------------------------------------------------ 13 | const schema = mongoose.Schema({ 14 | createdAt: { 15 | type: Date, 16 | default: Date.now, 17 | }, 18 | userId: { 19 | type: mongoose.Schema.Types.ObjectId, 20 | required: [true, 'User id is required'], 21 | index: true, 22 | }, 23 | endpoint: { 24 | type: String, 25 | }, 26 | keys: { 27 | auth: { 28 | type: String, 29 | }, 30 | p256dh: { 31 | type: String, 32 | }, 33 | }, 34 | }); 35 | //------------------------------------------------------------------------------ 36 | // INSTANCE METHODS: 37 | //------------------------------------------------------------------------------ 38 | 39 | //------------------------------------------------------------------------------ 40 | // STATIC METHODS: 41 | //------------------------------------------------------------------------------ 42 | schema.statics.findByEndpoint = async function ({ user, endpoint }) { 43 | if (!user || !user._id) { 44 | return null; 45 | } 46 | return this.findOne({ userId: user._id, endpoint }); 47 | }; 48 | //------------------------------------------------------------------------------ 49 | schema.statics.createSubscription = async function ({ user, endpoint, keys }) { 50 | if (!user || !user._id) { 51 | return null; 52 | } 53 | const newSub = new this({ userId: user._id, endpoint, keys }); 54 | await newSub.save(); 55 | return newSub; 56 | }; 57 | //------------------------------------------------------------------------------ 58 | schema.statics.findAll = async function ({ user }) { 59 | if (!user || !user._id) { 60 | return []; 61 | } 62 | return this.find({}).select({ endpoint: 1, keys: 1 }); 63 | }; 64 | //------------------------------------------------------------------------------ 65 | schema.statics.deleteByEndpoint = async function ({ user, endpoint }) { 66 | if (!user || !user._id) { 67 | return null; 68 | } 69 | 70 | // Make sure the user has permission 71 | const sub = await this.findOne({ userId: user._id, endpoint }); 72 | if (!sub) { 73 | return null; 74 | } 75 | 76 | await this.deleteOne({ _id: sub._id }); 77 | 78 | // Return the deleted subscription 79 | return sub; 80 | }; 81 | //------------------------------------------------------------------------------ 82 | // MONGOOSE MODEL: 83 | //------------------------------------------------------------------------------ 84 | const Subscription = mongoose.model('Subscription', schema); 85 | 86 | //------------------------------------------------------------------------------ 87 | // JOI: 88 | //------------------------------------------------------------------------------ 89 | const validatePush = (args) => { 90 | const joiKeys = Joi.object().keys({ 91 | auth: Joi.string().required(), 92 | p256dh: Joi.string().required(), 93 | }); 94 | 95 | const joiSubscription = Joi.object().keys({ 96 | endpoint: Joi.string().required(), 97 | keys: joiKeys, 98 | }); 99 | 100 | const joiSchema = { 101 | // subscriptions: Joi.array().items(joiSubscription), 102 | subscription: joiSubscription, 103 | title: Joi.string().min(MIN_STRING_LENGTH).max(MAX_STRING_LENGTH).required(), 104 | body: Joi.string().min(MIN_STRING_LENGTH).max(MAX_STRING_LENGTH).required(), 105 | icon: Joi.string().min(MIN_STRING_LENGTH).max(MAX_STRING_LENGTH), 106 | }; 107 | 108 | return Joi.validate(args, joiSchema); // { error, value } 109 | }; 110 | 111 | module.exports = { 112 | Subscription, 113 | validatePush, 114 | }; 115 | -------------------------------------------------------------------------------- /client/src/components/auth/email-form/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TextField from '@material-ui/core/TextField'; 4 | import Button from '@material-ui/core/Button'; 5 | import ErrorHandling from 'error-handling-utils'; 6 | import isEmail from 'validator/lib/isEmail'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // COMPONENT: 10 | //------------------------------------------------------------------------------ 11 | class EmailForm extends React.Component { 12 | state = { 13 | email: '', 14 | errors: { email: [] }, 15 | } 16 | 17 | handleChange = ({ target }) => { 18 | const { id: field, value } = target; 19 | const { errors } = this.state; 20 | 21 | // Update value and clear errors for the given field 22 | this.setState({ 23 | [field]: value, 24 | errors: ErrorHandling.clearErrors(errors, field), 25 | }); 26 | } 27 | 28 | validateFields = ({ email }) => { 29 | // Initialize errors 30 | const errors = { 31 | email: [], 32 | }; 33 | 34 | // Sanitize input 35 | const _email = email && email.trim(); // eslint-disable-line no-underscore-dangle 36 | 37 | if (!_email) { 38 | errors.email.push('Email is required!'); 39 | } else if (!isEmail(_email)) { 40 | // OBS: max chars is handled by isEmail function 41 | errors.email.push('Please, provide a valid email address!'); 42 | } 43 | 44 | return errors; 45 | } 46 | 47 | clearErrors = () => { 48 | this.setState({ errors: { email: [] } }); 49 | } 50 | 51 | handleSubmit = (evt) => { 52 | evt.preventDefault(); 53 | 54 | const { 55 | onBeforeHook, 56 | onClientCancelHook, 57 | onClientErrorHook, 58 | onSuccessHook, 59 | } = this.props; 60 | 61 | // Run before logic if provided and return on error 62 | try { 63 | onBeforeHook(); 64 | } catch (exc) { 65 | onClientCancelHook(); 66 | return; // return silently 67 | } 68 | 69 | // Get field values 70 | const { email } = this.state; 71 | 72 | // Clear previous errors if any 73 | this.clearErrors(); 74 | 75 | // Validate fields 76 | const errors = this.validateFields({ email }); 77 | 78 | // In case of errors, display on UI and return handler to parent component 79 | if (ErrorHandling.hasErrors(errors)) { 80 | this.setState({ errors }); 81 | onClientErrorHook(errors); 82 | return; 83 | } 84 | 85 | // Pass event up to parent component 86 | onSuccessHook({ email }); 87 | } 88 | 89 | render() { 90 | const { btnLabel, disabled } = this.props; 91 | const { email, errors } = this.state; 92 | 93 | const emailErrors = ErrorHandling.getFieldErrors(errors, 'email'); 94 | 95 | return ( 96 |
101 | 0} 110 | helperText={emailErrors || ''} 111 | /> 112 |
113 | 122 | 123 | ); 124 | } 125 | } 126 | 127 | EmailForm.propTypes = { 128 | btnLabel: PropTypes.string, 129 | disabled: PropTypes.bool, 130 | onBeforeHook: PropTypes.func, 131 | onClientCancelHook: PropTypes.func, 132 | onClientErrorHook: PropTypes.func, 133 | onSuccessHook: PropTypes.func, 134 | }; 135 | 136 | EmailForm.defaultProps = { 137 | btnLabel: 'Submit', 138 | disabled: false, 139 | onBeforeHook: () => {}, 140 | onClientCancelHook: () => {}, 141 | onClientErrorHook: () => {}, 142 | onSuccessHook: () => {}, 143 | }; 144 | 145 | export default EmailForm; 146 | -------------------------------------------------------------------------------- /client/public/push-listener.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // Push event listener aux function: 3 | function showNotification (event) { 4 | if (!(self.Notification && self.Notification.permission === 'granted')) { 5 | return; 6 | } 7 | 8 | console.log('\n\nevent.data', event.data); 9 | var data = event && event.data && event.data.json() || null; 10 | 11 | var title = data && data.title || 'Push notification demo'; 12 | var options = { 13 | body: data && data.body || 'Push message no payload', 14 | icon: data && data.icon || null, 15 | // tag: 'demo', 16 | // icon: '/img/apple-touch-icon.png', 17 | // badge: '/img/apple-touch-icon.png', 18 | // Custom actions buttons 19 | /* actions: [ 20 | { action: 'yes', title: 'I ♥ this app!' }, 21 | { action: 'no', title: 'I don\'t like this app' }, 22 | ], */ 23 | }; 24 | 25 | event.waitUntil( 26 | self.registration.showNotification(title, options), 27 | ); 28 | }; 29 | 30 | // When to Show Notifications: 31 | // If the user is already using your application there is no need to display a 32 | // notification. You can manage this logic on the server, but it is easier to 33 | // do it in the push handler inside your service worker: 34 | // the 'clients' global in the service worker lists all of the active push 35 | // clients on this machine. If there are no clients active, the user must be 36 | // in another app. We should show a notification in this case. If there are 37 | // active clients it means that the user has your site open in one or more 38 | // windows. The best practice is to relay the message to each of those windows. 39 | // Source: https://developers.google.com/web/ilt/pwa/introduction-to-push-notifications 40 | // Source: https://developers.google.com/web/fundamentals/codelabs/push-notifications/ 41 | self.addEventListener('push', function(event) { 42 | console.log('[Service Worker] Push Received.'); 43 | console.log('[Service Worker] Push had this data:', event && event.data); 44 | 45 | // Comment out the following line in case you only want to display 46 | // notifications when the app isn't open 47 | showNotification(event); 48 | 49 | clients.matchAll() 50 | .then((client) => { 51 | if (client.length === 0) { 52 | // Un-comment the following line in case you only want to display 53 | // notifications when the app isn't open 54 | // showNotification(event); 55 | } else { 56 | // Send a message to the page to update the UI 57 | console.log('Application is already open!'); 58 | } 59 | }); 60 | }); 61 | 62 | // The code below looks for the first window with 'visibilityState' set to 63 | // 'visible'. If one is found it navigates that client to the correct URL and 64 | // focuses the window. If a window that suits our needs is not found, it 65 | // opens a new window. 66 | // Source: https://developers.google.com/web/fundamentals/codelabs/push-notifications/ 67 | // Source: https://developers.google.com/web/ilt/pwa/introduction-to-push-notifications 68 | self.addEventListener('notificationclick', function (event) { 69 | console.log('[Service Worker] Notification click Received.'); 70 | 71 | var appUrl = new URL('/', location).href; 72 | 73 | // Listen to custom action buttons in push notification 74 | /* if (event.action === 'yes') { 75 | console.log('I ♥ this app!'); 76 | } else if (event.action === 'no') { 77 | console.log('I don\'t like this app'); 78 | } */ 79 | 80 | event.waitUntil( 81 | clients.matchAll() 82 | .then((clientsList) => { 83 | var client = clientsList.find(function(c) { 84 | return c.visibilityState === 'visible'; 85 | }); 86 | 87 | if (client !== undefined) { 88 | client.navigate(appUrl); 89 | client.focus(); 90 | } else { 91 | // There are no visible windows. Open one. 92 | clients.openWindow(appUrl); 93 | } 94 | }) 95 | , 96 | ); 97 | 98 | // Close all notifications (thisincludes any other notifications from the 99 | // same origin) 100 | // Source: https://developers.google.com/web/ilt/pwa/introduction-to-push-notifications 101 | self.registration.getNotifications() 102 | .then(function (notifications) { 103 | notifications.forEach(function (notification) { notification.close(); }); 104 | }); 105 | }); -------------------------------------------------------------------------------- /client/src/components/auth/passcode-form/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TextField from '@material-ui/core/TextField'; 4 | import Button from '@material-ui/core/Button'; 5 | import ErrorHandling from 'error-handling-utils'; 6 | 7 | //------------------------------------------------------------------------------ 8 | // COMPONENT: 9 | //------------------------------------------------------------------------------ 10 | // export const PASS_CODE_LENGTH = 6; 11 | 12 | //------------------------------------------------------------------------------ 13 | // COMPONENT: 14 | //------------------------------------------------------------------------------ 15 | class PasscodeForm extends React.Component { 16 | state = { 17 | passcode: '', 18 | errors: { passcode: [] }, 19 | } 20 | 21 | handleChange = ({ target }) => { 22 | const { errors } = this.state; 23 | const { id: field, value } = target; 24 | 25 | // Update value and clear errors for the given field 26 | this.setState({ 27 | [field]: value, 28 | errors: ErrorHandling.clearErrors(errors, field), 29 | }); 30 | } 31 | 32 | validateFields = ({ passcode }) => { 33 | // Initialize errors 34 | const errors = { 35 | passcode: [], 36 | }; 37 | 38 | // Sanitize input 39 | const _passcode = passcode && passcode.trim(); // eslint-disable-line no-underscore-dangle 40 | 41 | if (!_passcode) { 42 | errors.passcode.push('Pass code is required!'); 43 | } /* else if (_passcode.length !== PASS_CODE_LENGTH) { 44 | errors.passcode.push(`Pass code must be ${PASS_CODE_LENGTH} characters long`); 45 | } */ 46 | 47 | return errors; 48 | } 49 | 50 | clearErrors = () => { 51 | this.setState({ errors: { passcode: [] } }); 52 | } 53 | 54 | handleSubmit = (evt) => { 55 | evt.preventDefault(); 56 | 57 | const { 58 | onBeforeHook, 59 | onClientErrorHook, 60 | onClientCancelHook, 61 | onSuccessHook, 62 | } = this.props; 63 | 64 | // Run before logic if provided and return on error 65 | try { 66 | onBeforeHook(); 67 | } catch (exc) { 68 | onClientCancelHook(); 69 | return; // return silently 70 | } 71 | 72 | // Get field values 73 | const { passcode } = this.state; 74 | 75 | // Clear previous errors if any 76 | this.clearErrors(); 77 | 78 | // Validate fields 79 | const errors = this.validateFields({ passcode }); 80 | 81 | // In case of errors, display on UI and return handler to parent component 82 | if (ErrorHandling.hasErrors(errors)) { 83 | this.setState({ errors }); 84 | onClientErrorHook(errors); 85 | return; 86 | } 87 | 88 | // Pass event up to parent component 89 | onSuccessHook({ passcode }); 90 | } 91 | 92 | render() { 93 | const { btnLabel, disabled } = this.props; 94 | const { passcode, errors } = this.state; 95 | 96 | const passcodeErrors = ErrorHandling.getFieldErrors(errors, 'passcode'); 97 | 98 | return ( 99 |
104 | 0} 113 | helperText={passcodeErrors || ''} 114 | /> 115 |
116 | 125 | 126 | ); 127 | } 128 | } 129 | 130 | PasscodeForm.propTypes = { 131 | btnLabel: PropTypes.string, 132 | disabled: PropTypes.bool, 133 | onBeforeHook: PropTypes.func, 134 | onClientCancelHook: PropTypes.func, 135 | onClientErrorHook: PropTypes.func, 136 | onSuccessHook: PropTypes.func, 137 | }; 138 | 139 | PasscodeForm.defaultProps = { 140 | btnLabel: 'Submit', 141 | disabled: false, 142 | onBeforeHook: () => {}, 143 | onClientCancelHook: () => {}, 144 | onClientErrorHook: () => {}, 145 | onSuccessHook: () => {}, 146 | }; 147 | 148 | export default PasscodeForm; 149 | -------------------------------------------------------------------------------- /client/src/render-props/pwa-btn-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | //------------------------------------------------------------------------------ 5 | // PROPS AND METHODS PROVIDER: 6 | //------------------------------------------------------------------------------ 7 | // On development, SW won't be registered, see register-sw.js 8 | class PWABtnProps extends React.PureComponent { 9 | state = { 10 | supported: 'loading', // whether or not push notifications are supported 11 | subscribed: 'loading', // whether or not the user is subscribe to push notifications 12 | } 13 | 14 | async componentDidMount() { 15 | // Check that service workers are supported, if so, progressively enhance 16 | // and add push messaging support, otherwise continue without it 17 | if ('serviceWorker' in navigator) { 18 | try { 19 | await navigator.serviceWorker.ready; 20 | // Once the service worker is registered set the initial button state 21 | this.initialiseState(); 22 | } catch (exc) { 23 | console.log(exc); 24 | } 25 | } else { 26 | this.setSupported(false); 27 | this.setSubscribed(false); 28 | console.log('Service workers aren\'t supported in this browser.'); 29 | } 30 | } 31 | 32 | setSupported = (supported) => { 33 | this.setState(() => ({ supported })); 34 | } 35 | 36 | setSubscribed = (subscribed) => { 37 | this.setState(() => ({ subscribed })); 38 | } 39 | 40 | initialiseState = async () => { 41 | // Are notifications supported in the service worker? 42 | if (!('showNotification' in ServiceWorkerRegistration.prototype)) { 43 | console.log('Notifications aren\'t supported.'); 44 | this.setSupported(false); 45 | this.setSubscribed(false); 46 | return; 47 | } 48 | 49 | // Check the current notification permission. If its denied, it's a 50 | // permanent block until the user changes the permission 51 | if (Notification.permission === 'denied') { 52 | console.log('The user has blocked notifications.'); 53 | this.setSupported(false); 54 | this.setSubscribed(false); 55 | return; 56 | } 57 | 58 | // Check if push messaging is supported 59 | if (!('PushManager' in window)) { 60 | console.log('Push messaging isn\'t supported.'); 61 | this.setSupported(false); 62 | this.setSubscribed(false); 63 | return; 64 | } 65 | 66 | try { 67 | // We need the service worker registration to check for a subscription 68 | const registration = await navigator.serviceWorker.ready; 69 | 70 | // Do we already have a push message subscription? 71 | const subscription = await registration.pushManager.getSubscription(); 72 | 73 | // Enable any UI which subscribes / unsubscribes from push messages 74 | this.setSupported(true); 75 | 76 | if (!subscription) { 77 | // We aren’t subscribed to push, so set UI to allow the user to enable 78 | // push 79 | this.setSubscribed(false); 80 | return; 81 | } 82 | 83 | // Set your UI to show they have subscribed for push messages 84 | this.setSubscribed(true); 85 | } catch (exc) { 86 | console.log('Error during getSubscription()', exc); 87 | } 88 | } 89 | 90 | handleSubscriptionChange = ({ subscribed }) => { 91 | this.setSubscribed(subscribed); 92 | } 93 | 94 | render() { 95 | const { children } = this.props; 96 | const { supported, subscribed } = this.state; 97 | 98 | // Public API 99 | const api = { 100 | supported, 101 | subscribed, 102 | handleSubscriptionChange: this.handleSubscriptionChange, 103 | }; 104 | 105 | return children(api); 106 | } 107 | } 108 | 109 | PWABtnProps.propTypes = { 110 | children: PropTypes.oneOfType([ 111 | PropTypes.func, 112 | PropTypes.object, 113 | ]).isRequired, 114 | }; 115 | 116 | export default PWABtnProps; 117 | 118 | //------------------------------------------------------------------------------ 119 | // PROP TYPES: 120 | //------------------------------------------------------------------------------ 121 | export const pwaBtnPropTypes = { 122 | supported: PropTypes.oneOf([true, false, 'loading']).isRequired, 123 | subscribed: PropTypes.oneOf([true, false, 'loading']).isRequired, 124 | handleSubscriptionChange: PropTypes.func.isRequired, 125 | }; 126 | -------------------------------------------------------------------------------- /client/src/register-sw.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const { NODE_ENV, PUBLIC_URL } = process.env; 12 | 13 | const isProduction = NODE_ENV === 'production'; 14 | 15 | const isLocalhost = Boolean( 16 | window.location.hostname === 'localhost' 17 | // [::1] is the IPv6 localhost address. 18 | || window.location.hostname === '[::1]' 19 | // 127.0.0.1/8 is considered localhost for IPv4. 20 | || window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 21 | ); 22 | 23 | function registerValidSW(swUrl) { 24 | navigator.serviceWorker 25 | .register(swUrl) 26 | .then((registration) => { 27 | registration.onupdatefound = () => { 28 | const installingWorker = registration.installing; 29 | installingWorker.onstatechange = () => { 30 | if (installingWorker.state === 'installed') { 31 | if (navigator.serviceWorker.controller) { 32 | // At this point, the old content will have been purged and 33 | // the fresh content will have been added to the cache. 34 | // It's the perfect time to display a "New content is 35 | // available; please refresh." message in your web app. 36 | console.log('New content is available; please refresh.'); 37 | } else { 38 | // At this point, everything has been precached. 39 | // It's the perfect time to display a 40 | // "Content is cached for offline use." message. 41 | console.log('Content is cached for offline use.'); 42 | } 43 | } 44 | }; 45 | }; 46 | }) 47 | .catch((error) => { 48 | console.error('Error during service worker registration:', error); 49 | }); 50 | } 51 | 52 | function checkValidServiceWorker(swUrl) { 53 | // Check if the service worker can be found. If it can't reload the page. 54 | fetch(swUrl) 55 | .then((response) => { 56 | // Ensure service worker exists, and that we really are getting a JS file. 57 | if ( 58 | response.status === 404 || 59 | response.headers.get('content-type').indexOf('javascript') === -1 60 | ) { 61 | // No service worker found. Probably a different app. Reload the page. 62 | navigator.serviceWorker.ready.then((registration) => { 63 | registration.unregister().then(() => { 64 | window.location.reload(); 65 | }); 66 | }); 67 | } else { 68 | // Service worker found. Proceed as normal. 69 | registerValidSW(swUrl); 70 | } 71 | }) 72 | .catch(() => { 73 | console.log( 74 | 'No internet connection found. App is running in offline mode.' 75 | ); 76 | }); 77 | } 78 | 79 | export default function register() { 80 | if (isProduction && 'serviceWorker' in navigator) { 81 | // The URL constructor is available in all browsers that support SW. 82 | const publicUrl = new URL(PUBLIC_URL, window.location); 83 | if (publicUrl.origin !== window.location.origin) { 84 | // Our service worker won't work if PUBLIC_URL is on a different origin 85 | // from what our page is served on. This might happen if a CDN is used to 86 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 87 | return; 88 | } 89 | 90 | window.addEventListener('load', () => { 91 | const swUrl = `${PUBLIC_URL}/service-worker.js`; 92 | 93 | if (!isLocalhost) { 94 | // Is not local host. Just register service worker 95 | registerValidSW(swUrl); 96 | } else { 97 | // This is running on localhost. Lets check if a service worker still exists or not. 98 | checkValidServiceWorker(swUrl); 99 | } 100 | }); 101 | } else if (!isProduction) { 102 | console.log('SW not enabled on development'); 103 | } 104 | } 105 | 106 | export function unregister() { 107 | if ('serviceWorker' in navigator) { 108 | navigator.serviceWorker.ready.then((registration) => { 109 | registration.unregister(); 110 | }); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /client/src/components/auth/passcode-form/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { shallow } from 'enzyme'; 4 | import PasscodeForm from '.'; 5 | 6 | describe('PasscodeForm', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(, div); 10 | }); 11 | 12 | it('renders Submit if not button label is provided', () => { 13 | const wrapper = shallow(); 14 | expect(wrapper.find({ type: 'submit' }).children().text()).toBe('Submit'); 15 | }); 16 | 17 | it('renders custom label button', () => { 18 | const wrapper = shallow(); 19 | expect(wrapper.find({ type: 'submit' }).children().text()).toBe('Some label'); 20 | }); 21 | 22 | it('errors when form is submitted without passcode', () => { 23 | const handleClientError = jest.fn(); 24 | const wrapper = shallow(); 25 | 26 | expect(wrapper.find({ id: 'passcode' }).props().value).toBe(''); 27 | 28 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 29 | 30 | expect(wrapper.find({ id: 'passcode' }).props().error).toEqual(true); 31 | expect(wrapper.find({ id: 'passcode' }).props().helperText).toMatch(/is required/); 32 | expect(handleClientError).toBeCalled(); 33 | }); 34 | 35 | // it('errors when form is submitted with invalid passcode length', () => { 36 | // const handleClientError = jest.fn(); 37 | // const wrapper = shallow(); 38 | 39 | // expect(wrapper.find({ id: 'passcode' }).props().value).toBe(''); 40 | 41 | // wrapper.find({ id: 'passcode' }).simulate('change', { target: { id: 'passcode', value: 'invalid@email' } }); 42 | // wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 43 | 44 | // expect(wrapper.find({ id: 'passcode' }).props().error).toEqual(true); 45 | // expect(wrapper.find({ id: 'passcode' }).props().helperText).toMatch(/valid/); 46 | // expect(handleClientError).toBeCalled(); 47 | // }); 48 | 49 | it('clears errors when passcode input field is modified after error', () => { 50 | const handleClientError = jest.fn(); 51 | const wrapper = shallow(); 52 | 53 | expect(wrapper.find({ id: 'passcode' }).props().value).toBe(''); 54 | 55 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 56 | 57 | expect(wrapper.find({ id: 'passcode' }).props().error).toEqual(true); 58 | expect(wrapper.find({ id: 'passcode' }).props().helperText).toMatch(/is required/); 59 | expect(handleClientError).toBeCalled(); 60 | 61 | wrapper.find({ id: 'passcode' }).simulate('change', { target: { id: 'passcode', value: 'bla' } }); 62 | 63 | expect(wrapper.find({ id: 'passcode' }).props().error).toEqual(false); 64 | }); 65 | 66 | it('aborts form submission if onBeforeHook throws', () => { 67 | const handleBefore = jest.fn().mockImplementation(() => { throw new Error(); }); 68 | const handleClientCancel = jest.fn(); 69 | const handleSuccess = jest.fn(); 70 | 71 | const wrapper = shallow( 72 | , 77 | ); 78 | 79 | expect(wrapper.find({ id: 'passcode' }).props().value).toBe(''); 80 | 81 | wrapper.find({ id: 'passcode' }).simulate('change', { target: { id: 'passcode', value: '123456' } }); 82 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 83 | 84 | expect(handleBefore).toBeCalled(); 85 | expect(handleClientCancel).toBeCalled(); 86 | expect(handleSuccess).not.toBeCalled(); 87 | }); 88 | 89 | it('calls onSuccessHook when valid passcode and onBeforeHook doesn\'t throw', () => { 90 | const handleBefore = jest.fn(); 91 | const handleClientCancel = jest.fn(); 92 | const handleClientError = jest.fn(); 93 | const handleSuccess = jest.fn(); 94 | 95 | const wrapper = shallow( 96 | , 102 | ); 103 | 104 | expect(wrapper.find({ id: 'passcode' }).props().value).toBe(''); 105 | 106 | wrapper.find({ id: 'passcode' }).simulate('change', { target: { id: 'passcode', value: '123456' } }); 107 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 108 | 109 | expect(handleBefore).toBeCalled(); 110 | expect(handleClientCancel).not.toBeCalled(); 111 | expect(handleSuccess).toBeCalledWith( 112 | expect.objectContaining({ passcode: '123456' }), 113 | ); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /client/src/pages/home-page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { propType } from 'graphql-anywhere'; 3 | import styled from 'styled-components'; 4 | import { PWABtnProps, FormProps } from '../../render-props'; 5 | import { withUser } from '../../global-data-provider'; 6 | import userFragment from '../../graphql/user/fragment/user'; 7 | import LogoutBtn from '../../components/auth/logout-btn'; 8 | import SubscribeBtn from '../../components/pwa/subscribe-btn'; 9 | import UnsubscribeBtn from '../../components/pwa/unsubscribe-btn'; 10 | import PushBtn from '../../components/pwa/push-btn'; 11 | import Title from '../../components/common/title'; 12 | import Feedback from '../../components/common/feedback'; 13 | import Alert from '../../components/common/alert'; 14 | import Loading from '../../components/common/loading'; 15 | 16 | //------------------------------------------------------------------------------ 17 | // STYLE: 18 | //------------------------------------------------------------------------------ 19 | const Json = styled.pre` 20 | word-wrap: break-word; 21 | white-space: pre-wrap; 22 | `; 23 | //------------------------------------------------------------------------------ 24 | // COMPONENT: 25 | //------------------------------------------------------------------------------ 26 | const HomePage = ({ curUser }) => ( 27 |
28 | Home Page 29 |
30 |

Current User

31 | 32 | {JSON.stringify(curUser, null, 2)} 33 | 34 |
35 | 36 |
37 | 38 | {(pwaBtnProps) => { 39 | const { 40 | supported, 41 | subscribed, 42 | handleSubscriptionChange, 43 | } = pwaBtnProps; 44 | 45 | return ( 46 | 47 | {({ 48 | disabled, 49 | errorMsg, 50 | successMsg, 51 | handleBefore, 52 | handleClientCancel, 53 | handleServerError, 54 | handleSuccess, 55 | }) => { 56 | // Display loading indicator while checking for push support 57 | if (supported === 'loading') { 58 | return ; 59 | } 60 | 61 | // Do not render subscribe and push notification buttons in case 62 | // notifications aren't supported 63 | if (!supported) { 64 | return ( 65 | 69 | ); 70 | } 71 | 72 | return ( 73 | 74 |

Enable Push notifications

75 | {subscribed ? ( 76 | { 82 | handleSubscriptionChange({ subscribed: false }); 83 | handleSuccess(); 84 | }} 85 | /> 86 | ) : ( 87 | { 93 | handleSubscriptionChange({ subscribed: true }); 94 | handleSuccess(); 95 | }} 96 | /> 97 | )} 98 |
99 | {subscribed && ( 100 | 107 | )} 108 |
109 | 115 | 116 | ); 117 | }} 118 | 119 | ); 120 | }} 121 | 122 |
123 | ); 124 | 125 | HomePage.propTypes = { 126 | curUser: propType(userFragment).isRequired, 127 | }; 128 | 129 | export default withUser(HomePage); 130 | -------------------------------------------------------------------------------- /client/src/components/auth/email-form/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { shallow } from 'enzyme'; 4 | import EmailForm from '.'; 5 | 6 | describe('EmailForm', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(, div); 10 | }); 11 | 12 | it('renders Submit if not button label is provided', () => { 13 | const wrapper = shallow(); 14 | expect(wrapper.find({ type: 'submit' }).children().text()).toBe('Submit'); 15 | }); 16 | 17 | it('renders custom label button', () => { 18 | const wrapper = shallow(); 19 | expect(wrapper.find({ type: 'submit' }).children().text()).toBe('Some label'); 20 | }); 21 | 22 | it('errors when form is submitted without email', () => { 23 | const handleClientError = jest.fn(); 24 | const wrapper = shallow(); 25 | 26 | // Sanity check 27 | expect(wrapper.find({ id: 'email' }).props().value).toBe(''); 28 | 29 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 30 | 31 | expect(wrapper.find({ id: 'email' }).props().error).toEqual(true); 32 | expect(wrapper.find({ id: 'email' }).props().helperText).toMatch(/is required/); 33 | expect(handleClientError).toBeCalled(); 34 | }); 35 | 36 | it('errors when form is submitted with invalid email', () => { 37 | const handleClientError = jest.fn(); 38 | const wrapper = shallow(); 39 | 40 | expect(wrapper.find({ id: 'email' }).props().value).toBe(''); 41 | 42 | wrapper.find({ id: 'email' }).simulate('change', { target: { id: 'email', value: 'invalid@email' } }); 43 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 44 | 45 | expect(wrapper.find({ id: 'email' }).props().error).toEqual(true); 46 | expect(wrapper.find({ id: 'email' }).props().helperText).toMatch(/valid/); 47 | expect(handleClientError).toBeCalled(); 48 | }); 49 | 50 | it('clears errors when email input field is modified after error', () => { 51 | const handleClientError = jest.fn(); 52 | const wrapper = shallow(); 53 | 54 | expect(wrapper.find({ id: 'email' }).props().value).toBe(''); 55 | 56 | wrapper.find({ id: 'email' }).simulate('change', { target: { id: 'email', value: 'invalid@email' } }); 57 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 58 | 59 | expect(wrapper.find({ id: 'email' }).props().error).toEqual(true); 60 | expect(wrapper.find({ id: 'email' }).props().helperText).toMatch(/valid/); 61 | expect(handleClientError).toBeCalled(); 62 | 63 | wrapper.find({ id: 'email' }).simulate('change', { target: { id: 'email', value: 'other_invalid@email' } }); 64 | 65 | expect(wrapper.find({ id: 'email' }).props().error).toEqual(false); 66 | }); 67 | 68 | it('aborts form submission if onBeforeHook throws', () => { 69 | const handleBefore = jest.fn().mockImplementation(() => { throw new Error(); }); 70 | const handleClientCancel = jest.fn(); 71 | const handleSuccess = jest.fn(); 72 | 73 | const wrapper = shallow( 74 | , 79 | ); 80 | 81 | expect(wrapper.find({ id: 'email' }).props().value).toBe(''); 82 | 83 | wrapper.find({ id: 'email' }).simulate('change', { target: { id: 'email', value: 'valid@email.com' } }); 84 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 85 | 86 | expect(handleBefore).toBeCalled(); 87 | expect(handleClientCancel).toBeCalled(); 88 | expect(handleSuccess).not.toBeCalled(); 89 | }); 90 | 91 | it('calls onSuccessHook when valid email and onBeforeHook doesn\'t throw', () => { 92 | const handleBefore = jest.fn(); 93 | const handleClientCancel = jest.fn(); 94 | const handleClientError = jest.fn(); 95 | const handleSuccess = jest.fn(); 96 | 97 | const wrapper = shallow( 98 | , 104 | ); 105 | 106 | expect(wrapper.find({ id: 'email' }).props().value).toBe(''); 107 | 108 | wrapper.find({ id: 'email' }).simulate('change', { target: { id: 'email', value: 'valid@email.com' } }); 109 | 110 | expect(wrapper.state().email).toBe('valid@email.com'); 111 | 112 | wrapper.find('form').simulate('submit', { preventDefault: () => {} }); 113 | 114 | expect(handleBefore).toBeCalled(); 115 | expect(handleClientCancel).not.toBeCalled(); 116 | expect(handleSuccess).toBeCalledWith( 117 | expect.objectContaining({ email: 'valid@email.com' }), 118 | ); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /client/src/components/pwa/subscribe-btn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import Button from '@material-ui/core/Button'; 5 | // import userQuery from '../../graphql/user/query/user'; 6 | import saveSubscriptionMutation from '../../graphql/subscription/mutation/save-subscription'; 7 | 8 | // const { publicKey: vapidPublicKey } = Meteor.settings.public.vapid; 9 | 10 | const { REACT_APP_VAPID_PUBLIC_KEY } = process.env; 11 | 12 | if (!REACT_APP_VAPID_PUBLIC_KEY || REACT_APP_VAPID_PUBLIC_KEY.length === 0) { 13 | throw new Error('FATAL ERROR: REACT_APP_VAPID_PUBLIC_KEY env var missing'); 14 | } 15 | 16 | //------------------------------------------------------------------------------ 17 | // AUX FUNCTIONS: 18 | //------------------------------------------------------------------------------ 19 | // Source: https://www.npmjs.com/package/web-push 20 | // When using your VAPID key in your web app, you'll need to convert the URL 21 | // safe base64 string to a Uint8Array to pass into the subscribe call, which you 22 | // can do like so: 23 | function urlBase64ToUint8Array(base64String) { 24 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4); 25 | const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); // eslint-disable-line 26 | 27 | const rawData = window.atob(base64); 28 | const outputArray = new Uint8Array(rawData.length); 29 | 30 | for (let i = 0; i < rawData.length; i += 1) { 31 | outputArray[i] = rawData.charCodeAt(i); 32 | } 33 | return outputArray; 34 | } 35 | //------------------------------------------------------------------------------ 36 | // COMPONENT: 37 | //------------------------------------------------------------------------------ 38 | // Source: https://github.com/GoogleChrome/samples/blob/gh-pages/push-messaging-and-notifications/main.js 39 | class SubscribeBtn extends React.PureComponent { 40 | handleClick = async () => { 41 | const { 42 | saveSubscription, 43 | onBeforeHook, 44 | onClientCancelHook, 45 | onClientErrorHook, 46 | onServerErrorHook, 47 | onSuccessHook, 48 | } = this.props; 49 | 50 | // Run before logic if provided and return on error 51 | try { 52 | onBeforeHook(); 53 | } catch (exc) { 54 | onClientCancelHook(); 55 | return; // return silently 56 | } 57 | 58 | let subscription = null; 59 | 60 | try { 61 | // We need the service worker registration to create the subscription 62 | const registration = await navigator.serviceWorker.ready; 63 | 64 | // Register subscription 65 | subscription = await registration.pushManager.subscribe({ 66 | userVisibleOnly: true, // always show notification when received 67 | applicationServerKey: urlBase64ToUint8Array(REACT_APP_VAPID_PUBLIC_KEY), 68 | }); 69 | } catch (exc) { 70 | if (Notification.permission === 'denied') { 71 | // The user denied the notification permission which means we failed to 72 | // subscribe and the user will need to manually change the notification 73 | // permission to subscribe to push messages 74 | onClientErrorHook('Permission for Notifications was denied'); 75 | } else { 76 | // A problem occurred with the subscription, this can often be down to 77 | // an issue or lack of the gcm_sender_id and / or gcm_user_visible_only 78 | const err = { reason: `Unable to subscribe to push, ${exc}` }; 79 | onClientErrorHook(err); 80 | } 81 | } 82 | 83 | try { 84 | // Get subscription enpoint, public key and the shared secret 85 | const { endpoint } = subscription; 86 | const p256dh = subscription.getKey('p256dh'); 87 | const auth = subscription.getKey('auth'); 88 | 89 | // Encode the public key and the shared secret (which are in bytes) into 90 | // base64 format to transmit over HTTP 91 | const encSubscription = { 92 | endpoint, 93 | keys: { 94 | p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(p256dh))), 95 | auth: btoa(String.fromCharCode.apply(null, new Uint8Array(auth))), 96 | }, 97 | }; 98 | 99 | // Send the subscription to your server and save it to send a push message 100 | // at a later date. 101 | await saveSubscription({ 102 | variables: { subscription: encSubscription }, 103 | // refetchQueries: [{ query: userQuery }], 104 | }); 105 | 106 | onSuccessHook(); 107 | } catch (exc) { 108 | onServerErrorHook(exc); 109 | } 110 | } 111 | 112 | render() { 113 | const { btnLabel, disabled } = this.props; 114 | 115 | return ( 116 | 124 | ); 125 | } 126 | } 127 | 128 | SubscribeBtn.propTypes = { 129 | btnLabel: PropTypes.string, 130 | disabled: PropTypes.bool, 131 | saveSubscription: PropTypes.func.isRequired, 132 | onBeforeHook: PropTypes.func, 133 | onClientCancelHook: PropTypes.func, 134 | onClientErrorHook: PropTypes.func, 135 | onServerErrorHook: PropTypes.func, 136 | onSuccessHook: PropTypes.func, 137 | }; 138 | 139 | SubscribeBtn.defaultProps = { 140 | btnLabel: 'Enable Push Messages', 141 | disabled: false, 142 | onBeforeHook: () => {}, 143 | onClientCancelHook: () => {}, 144 | onClientErrorHook: () => {}, 145 | onServerErrorHook: () => {}, 146 | onSuccessHook: () => {}, 147 | }; 148 | 149 | const withMutation = graphql(saveSubscriptionMutation, { name: 'saveSubscription' }); 150 | 151 | export default withMutation(SubscribeBtn); 152 | -------------------------------------------------------------------------------- /server/src/models/user/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | const mongoose = require('mongoose'); 3 | const bcrypt = require('bcrypt'); 4 | const { isEmail } = require('validator'); 5 | const Joi = require('joi'); 6 | const jwt = require('jsonwebtoken'); 7 | const moment = require('moment'); 8 | 9 | //------------------------------------------------------------------------------ 10 | // CONSTANTS: 11 | //------------------------------------------------------------------------------ 12 | const { JWT_PRIVATE_KEY } = process.env; 13 | 14 | const MIN_STRING_LENGTH = 2; 15 | const MAX_STRING_LENGTH = 255; 16 | const PASS_CODE_LENGTH = 6; // plain text passcode length 17 | //------------------------------------------------------------------------------ 18 | // AUX FUNCTIONS: 19 | //------------------------------------------------------------------------------ 20 | const getExpDate = () => ( 21 | // Five minutes from now 22 | moment().add(5, 'minutes').toISOString() 23 | // moment().add(5, 'seconds').toISOString() 24 | ); 25 | //------------------------------------------------------------------------------ 26 | // MONGOOSE SCHEMA: 27 | //------------------------------------------------------------------------------ 28 | const schema = mongoose.Schema({ 29 | createdAt: { 30 | type: Date, 31 | default: Date.now, 32 | }, 33 | email: { 34 | type: String, 35 | trim: true, 36 | lowercase: true, 37 | minlength: MIN_STRING_LENGTH, 38 | maxlength: MAX_STRING_LENGTH, 39 | unique: true, 40 | required: [true, 'Email address is required'], 41 | validate: [isEmail, 'Please fill a valid email address'], 42 | }, 43 | emailVerified: { 44 | type: Boolean, 45 | default: false, 46 | }, 47 | passcode: { 48 | type: String, 49 | maxlength: 1024, // hashed passcode 50 | }, 51 | expirationDate: { // pass code expiration date 52 | type: Date, 53 | }, 54 | // TODO: see jti or jwt balcklist to prevent stolen tokens to pass validation 55 | // See: https://medium.com/react-native-training/building-chatty-part-7-authentication-in-graphql-cd37770e5ab3 56 | }); 57 | //------------------------------------------------------------------------------ 58 | // INSTANCE METHODS: 59 | //------------------------------------------------------------------------------ 60 | schema.methods.validatePasscode = function ({ passcode }) { 61 | return ( 62 | passcode 63 | && this.passcode 64 | && bcrypt.compare(passcode.toString(), this.passcode) 65 | ); 66 | }; 67 | //------------------------------------------------------------------------------ 68 | schema.methods.passcodeExpired = function () { 69 | if (!this.expirationDate) { 70 | return true; // expired 71 | } 72 | 73 | const now = moment(); 74 | // console.log('NOW', now.clone().toISOString()); 75 | const expDate = moment(this.expirationDate); 76 | // console.log('EXP_DATE', expDate.clone().toISOString()); 77 | // console.log('DIFF', expDate.diff(now)); 78 | return expDate.diff(now) < 0; 79 | }; 80 | //------------------------------------------------------------------------------ 81 | schema.methods.genPasscode = async function (digits) { 82 | // TODO: Math.random() does not provide cryptographically secure random numbers. 83 | // Do not use them for anything related to security. Use the Web Crypto API 84 | // instead, and more precisely the window.crypto.getRandomValues() method. 85 | const passcode = Math.floor(Math.random() * (10 ** digits)); 86 | 87 | const salt = await bcrypt.genSalt(10); 88 | const hash = await bcrypt.hash(passcode.toString(), salt); 89 | 90 | this.passcode = hash; 91 | this.expirationDate = getExpDate(); 92 | await this.save(); 93 | 94 | return passcode; // plain text passcode 95 | }; 96 | //------------------------------------------------------------------------------ 97 | schema.methods.setEmailToVerified = async function () { 98 | this.emailVerified = true; 99 | await this.save(); 100 | }; 101 | //------------------------------------------------------------------------------ 102 | schema.methods.genAuthToken = function () { 103 | return jwt.sign({ _id: this._id }, JWT_PRIVATE_KEY); 104 | }; 105 | //------------------------------------------------------------------------------ 106 | // STATIC METHODS: 107 | //------------------------------------------------------------------------------ 108 | schema.statics.findById = function ({ _id }) { 109 | return this.findOne({ _id }); 110 | }; 111 | //------------------------------------------------------------------------------ 112 | schema.statics.findByEmail = function ({ email }) { 113 | return this.findOne({ email }); 114 | }; 115 | //------------------------------------------------------------------------------ 116 | schema.statics.createUser = async function ({ email }) { 117 | const newUser = new this({ email }); 118 | await newUser.save(); 119 | return newUser; 120 | }; 121 | //------------------------------------------------------------------------------ 122 | // MONGOOSE MODEL: 123 | //------------------------------------------------------------------------------ 124 | const User = mongoose.model('User', schema); 125 | 126 | //------------------------------------------------------------------------------ 127 | // JOI: 128 | //------------------------------------------------------------------------------ 129 | const emailVal = Joi.string().email().min(MIN_STRING_LENGTH).max(MAX_STRING_LENGTH).required(); // eslint-disable-line 130 | const passcodeVal = Joi.number().integer().min(0).max(Math.pow(10, PASS_CODE_LENGTH + 1)).required(); // eslint-disable-line 131 | 132 | const validateSignup = (user) => { 133 | const joiSchema = { 134 | email: emailVal, 135 | }; 136 | 137 | return Joi.validate(user, joiSchema); // { error, value } 138 | }; 139 | 140 | const validateLogin = (credentials) => { 141 | const joiSchema = { 142 | email: emailVal, 143 | passcode: passcodeVal, 144 | }; 145 | 146 | return Joi.validate(credentials, joiSchema); // { error, value } 147 | }; 148 | 149 | module.exports = { 150 | User, 151 | validateSignup, 152 | validateLogin, 153 | }; 154 | -------------------------------------------------------------------------------- /client/src/pages/login-page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withApollo } from 'react-apollo'; 4 | import { FormProps } from '../../render-props'; 5 | // import { FormProps } from 'react-state-helpers-via-render-props'; 6 | // import SEO from '../../components/smart/seo'; 7 | import EmailForm from '../../components/auth/email-form'; 8 | import PasscodeForm from '../../components/auth/passcode-form'; 9 | import SendPasscode from '../../components/auth/send-passcode'; 10 | import LoginApiCall from '../../components/auth/login-api-call'; 11 | import ResendPasscodeBtn from '../../components/auth/resend-passcode-btn'; 12 | import AuthPageLayout from '../../layouts/auth-page'; 13 | import Feedback from '../../components/common/feedback'; 14 | import ButtonLink from '../../components/common/button-link'; 15 | 16 | //------------------------------------------------------------------------------ 17 | // COMPONENT: 18 | //------------------------------------------------------------------------------ 19 | // After PasscodeAuthView returns successful, the user logged-in-state will change 20 | // from 'logged out' to 'logged in' automatically. This will trigger the 21 | // LoggedOutRoute component's logic (said component wraps the LoginPage component) 22 | // which will result in redirecting the user to home page automatically. 23 | class LoginPage extends React.PureComponent { 24 | state = { 25 | view: 'emailView', 26 | email: '', 27 | } 28 | 29 | render() { 30 | const { client, onPageChange } = this.props; 31 | const { view, email } = this.state; 32 | 33 | const signupLink = ( 34 | { onPageChange('signup'); }}> 35 | Sign Up 36 | 37 | ); 38 | 39 | return ( 40 | 41 | {({ 42 | disabled, 43 | errorMsg, 44 | successMsg, 45 | setSuccessMessage, 46 | handleBefore, 47 | handleClientCancel, 48 | handleClientError, 49 | handleServerError, 50 | handleSuccess, 51 | }) => ( 52 | { 66 | // Extend formProps.handleSuccess' default functionality 67 | handleSuccess(() => { 68 | // Show success message after action is completed 69 | setSuccessMessage('A new email has been sent to your inbox!'); 70 | }); 71 | }} 72 | /> 73 | ) 74 | } 75 | > 76 | {view === 'emailView' && ( 77 | { 80 | // Extend formProps.handleSuccess' default functionality 81 | handleSuccess(() => { 82 | // Show success message after action is completed 83 | setSuccessMessage('A new email has been sent to your inbox!'); 84 | // Switch to passcodeView view 85 | this.setState({ view: 'passcodeView' }); 86 | }); 87 | }} 88 | > 89 | {({ sendPasscode }) => ( 90 | { 97 | // Store current user's email and fire signup api call 98 | this.setState( 99 | { email: inputFields.email }, 100 | () => { sendPasscode({ email: inputFields.email }); }, 101 | ); 102 | }} 103 | /> 104 | )} 105 | 106 | )} 107 | {view === 'passcodeView' && ( 108 | { 112 | // Extend formProps.handleSuccess' default functionality 113 | handleSuccess(() => { 114 | // Store token into browser and resetStore to update client data 115 | localStorage.setItem('x-auth-token', token); 116 | client.resetStore(); 117 | }); 118 | }} 119 | > 120 | {({ loginUser }) => ( 121 | 130 | )} 131 | 132 | )} 133 |
134 | 139 | 140 | )} 141 | 142 | ); 143 | } 144 | } 145 | 146 | LoginPage.propTypes = { 147 | client: PropTypes.shape({ 148 | resetStore: PropTypes.func.isRequired, 149 | }).isRequired, 150 | onPageChange: PropTypes.func, 151 | }; 152 | 153 | LoginPage.defaultProps = { 154 | onPageChange: () => {}, 155 | }; 156 | 157 | export default withApollo(LoginPage); 158 | -------------------------------------------------------------------------------- /client/src/pages/signup-page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withApollo } from 'react-apollo'; 4 | import { FormProps } from '../../render-props'; 5 | // import { FormProps } from 'react-state-helpers-via-render-props'; 6 | // import SEO from '../../components/smart/seo'; 7 | import EmailForm from '../../components/auth/email-form'; 8 | import PasscodeForm from '../../components/auth/passcode-form'; 9 | import SignupApiCall from '../../components/auth/signup-api-call'; 10 | import SendPasscode from '../../components/auth/send-passcode'; 11 | import LoginApiCall from '../../components/auth/login-api-call'; 12 | import ResendPasscodeBtn from '../../components/auth/resend-passcode-btn'; 13 | import AuthPageLayout from '../../layouts/auth-page'; 14 | import Feedback from '../../components/common/feedback'; 15 | import ButtonLink from '../../components/common/button-link'; 16 | 17 | //------------------------------------------------------------------------------ 18 | // COMPONENT: 19 | //------------------------------------------------------------------------------ 20 | // After PasscodeAuthView returns successful, the user logged-in-state will change 21 | // from 'logged out' to 'logged in' automatically. This will trigger the 22 | // LoggedOutRoute component's logic (said component wraps the SignupPage component) 23 | // which will result in redirecting the user to home page automatically. 24 | class SignupPage extends React.PureComponent { 25 | state = { 26 | view: 'emailView', 27 | email: '', 28 | } 29 | 30 | render() { 31 | const { client, onPageChange } = this.props; 32 | const { view, email } = this.state; 33 | 34 | const loginLink = ( 35 | { onPageChange('login'); }}> 36 | Log In 37 | 38 | ); 39 | 40 | return ( 41 | 42 | {({ 43 | disabled, 44 | errorMsg, 45 | successMsg, 46 | setSuccessMessage, 47 | handleBefore, 48 | handleClientCancel, 49 | handleClientError, 50 | handleServerError, 51 | handleSuccess, 52 | }) => ( 53 | { 67 | // Extend formProps.handleSuccess' default functionality 68 | handleSuccess(() => { 69 | // Show success message after action is completed 70 | setSuccessMessage('A new email has been sent to your inbox!'); 71 | }); 72 | }} 73 | /> 74 | ) 75 | } 76 | > 77 | {view === 'emailView' && ( 78 | { 81 | // Extend formProps.handleSuccess' default functionality 82 | handleSuccess(() => { 83 | // Show success message after action is completed 84 | setSuccessMessage('A new email has been sent to your inbox!'); 85 | // Switch to passcodeView view 86 | this.setState({ view: 'passcodeView' }); 87 | }); 88 | }} 89 | > 90 | {({ sendPasscode }) => ( 91 | { 94 | sendPasscode({ email: newUser.email }); 95 | }} 96 | > 97 | {({ signupUser }) => ( 98 | { 105 | // Store current user's email and fire signup api call 106 | this.setState( 107 | { email: inputFields.email }, 108 | () => { signupUser({ email: inputFields.email }); }, 109 | ); 110 | }} 111 | /> 112 | )} 113 | 114 | )} 115 | 116 | )} 117 | {view === 'passcodeView' && ( 118 | { 122 | // Extend formProps.handleSuccess' default functionality 123 | handleSuccess(() => { 124 | // Store token into browser and resetStore to update client data 125 | localStorage.setItem('x-auth-token', token); 126 | client.resetStore(); 127 | }); 128 | }} 129 | > 130 | {({ loginUser }) => ( 131 | 140 | )} 141 | 142 | )} 143 |
144 | 149 | 150 | )} 151 | 152 | ); 153 | } 154 | } 155 | 156 | SignupPage.propTypes = { 157 | client: PropTypes.shape({ 158 | resetStore: PropTypes.func.isRequired, 159 | }).isRequired, 160 | onPageChange: PropTypes.func, 161 | }; 162 | 163 | SignupPage.defaultProps = { 164 | onPageChange: () => {}, 165 | }; 166 | 167 | export default withApollo(SignupPage); 168 | -------------------------------------------------------------------------------- /client/service-worker.tmpl: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // DO NOT EDIT THIS GENERATED OUTPUT DIRECTLY! 18 | // This file should be overwritten as part of your build process. 19 | // If you need to extend the behavior of the generated service worker, the best approach is to write 20 | // additional code and include it using the importScripts option: 21 | // https://github.com/GoogleChrome/sw-precache#importscripts-arraystring 22 | // 23 | // Alternatively, it's possible to make changes to the underlying template file and then use that as the 24 | // new base for generating output, via the templateFilePath option: 25 | // https://github.com/GoogleChrome/sw-precache#templatefilepath-string 26 | // 27 | // If you go that route, make sure that whenever you update your sw-precache dependency, you reconcile any 28 | // changes made to this original template file with your modified copy. 29 | 30 | // This generated service worker JavaScript will precache your site's resources. 31 | // The code needs to be saved in a .js file at the top-level of your site, and registered 32 | // from your pages in order to be used. See 33 | // https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js 34 | // for an example of how you can register this script and handle various service worker events. 35 | 36 | /* eslint-env worker, serviceworker */ 37 | /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 38 | 'use strict'; 39 | 40 | var precacheConfig = <%= precacheConfig %>; 41 | var cacheName = 'sw-precache-<%= version %>-<%= cacheId %>-' + (self.registration ? self.registration.scope : ''); 42 | 43 | <% if (handleFetch) { %> 44 | var ignoreUrlParametersMatching = [<%= ignoreUrlParametersMatching %>]; 45 | <% } %> 46 | 47 | <% Object.keys(externalFunctions).sort().forEach(function(functionName) {%> 48 | var <%- functionName %> = <%= externalFunctions[functionName] %>; 49 | <% }); %> 50 | 51 | var hashParamName = '_sw-precache'; 52 | var urlsToCacheKeys = new Map( 53 | precacheConfig.map(function(item) { 54 | var relativeUrl = item[0]; 55 | var hash = item[1]; 56 | var absoluteUrl = new URL(relativeUrl, self.location); 57 | var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, <%= dontCacheBustUrlsMatching %>); 58 | return [absoluteUrl.toString(), cacheKey]; 59 | }) 60 | ); 61 | 62 | function setOfCachedUrls(cache) { 63 | return cache.keys().then(function(requests) { 64 | return requests.map(function(request) { 65 | return request.url; 66 | }); 67 | }).then(function(urls) { 68 | return new Set(urls); 69 | }); 70 | } 71 | 72 | self.addEventListener('install', function(event) { 73 | event.waitUntil( 74 | caches.open(cacheName).then(function(cache) { 75 | return setOfCachedUrls(cache).then(function(cachedUrls) { 76 | return Promise.all( 77 | Array.from(urlsToCacheKeys.values()).map(function(cacheKey) { 78 | // If we don't have a key matching url in the cache already, add it. 79 | if (!cachedUrls.has(cacheKey)) { 80 | var request = new Request(cacheKey, {credentials: 'same-origin'}); 81 | return fetch(request).then(function(response) { 82 | // Bail out of installation unless we get back a 200 OK for 83 | // every request. 84 | if (!response.ok) { 85 | throw new Error('Request for ' + cacheKey + ' returned a ' + 86 | 'response with status ' + response.status); 87 | } 88 | 89 | return cleanResponse(response).then(function(responseToCache) { 90 | return cache.put(cacheKey, responseToCache); 91 | }); 92 | }); 93 | } 94 | }) 95 | ); 96 | }); 97 | }).then(function() { 98 | <% if (skipWaiting) { %> 99 | // Force the SW to transition from installing -> active state 100 | return self.skipWaiting(); 101 | <% } %> 102 | }) 103 | ); 104 | }); 105 | 106 | self.addEventListener('activate', function(event) { 107 | var setOfExpectedUrls = new Set(urlsToCacheKeys.values()); 108 | 109 | event.waitUntil( 110 | caches.open(cacheName).then(function(cache) { 111 | return cache.keys().then(function(existingRequests) { 112 | return Promise.all( 113 | existingRequests.map(function(existingRequest) { 114 | if (!setOfExpectedUrls.has(existingRequest.url)) { 115 | return cache.delete(existingRequest); 116 | } 117 | }) 118 | ); 119 | }); 120 | }).then(function() { 121 | <% if (clientsClaim) { %> 122 | return self.clients.claim(); 123 | <% } %> 124 | }) 125 | ); 126 | }); 127 | 128 | <% if (handleFetch) { %> 129 | self.addEventListener('fetch', function(event) { 130 | if (event.request.method === 'GET') { 131 | // Should we call event.respondWith() inside this fetch event handler? 132 | // This needs to be determined synchronously, which will give other fetch 133 | // handlers a chance to handle the request if need be. 134 | var shouldRespond; 135 | 136 | // First, remove all the ignored parameters and hash fragment, and see if we 137 | // have that URL in our cache. If so, great! shouldRespond will be true. 138 | var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching); 139 | shouldRespond = urlsToCacheKeys.has(url); 140 | 141 | // If shouldRespond is false, check again, this time with 'index.html' 142 | // (or whatever the directoryIndex option is set to) at the end. 143 | var directoryIndex = '<%= directoryIndex %>'; 144 | if (!shouldRespond && directoryIndex) { 145 | url = addDirectoryIndex(url, directoryIndex); 146 | shouldRespond = urlsToCacheKeys.has(url); 147 | } 148 | 149 | // If shouldRespond is still false, check to see if this is a navigation 150 | // request, and if so, whether the URL matches navigateFallbackWhitelist. 151 | var navigateFallback = '<%= navigateFallback %>'; 152 | if (!shouldRespond && 153 | navigateFallback && 154 | (event.request.mode === 'navigate') && 155 | isPathWhitelisted(<%= navigateFallbackWhitelist %>, event.request.url)) { 156 | url = new URL(navigateFallback, self.location).toString(); 157 | shouldRespond = urlsToCacheKeys.has(url); 158 | } 159 | 160 | // If shouldRespond was set to true at any point, then call 161 | // event.respondWith(), using the appropriate cache key. 162 | if (shouldRespond) { 163 | event.respondWith( 164 | caches.open(cacheName).then(function(cache) { 165 | return cache.match(urlsToCacheKeys.get(url)).then(function(response) { 166 | if (response) { 167 | return response; 168 | } 169 | throw Error('The cached response that was expected is missing.'); 170 | }); 171 | }).catch(function(e) { 172 | // Fall back to just fetch()ing the request if some unexpected error 173 | // prevented the cached response from being valid. 174 | console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e); 175 | return fetch(event.request); 176 | }) 177 | ); 178 | } 179 | } 180 | }); 181 | 182 | <% if (swToolboxCode) { %> 183 | // *** Start of auto-included sw-toolbox code. *** 184 | <%= swToolboxCode %> 185 | // *** End of auto-included sw-toolbox code. *** 186 | <% } %> 187 | 188 | <% if (runtimeCaching) { %> 189 | // Runtime cache configuration, using the sw-toolbox library. 190 | <%= runtimeCaching %> 191 | <% } %> 192 | <% } %> 193 | 194 | <% if (importScripts) { %> 195 | importScripts(<%= importScripts %>); 196 | <% } %> --------------------------------------------------------------------------------