├── .babelrc ├── .docker-compose.yml ├── .dockerignore ├── .env.client.example ├── .env.server.example ├── .eslintrc ├── .flowconfig ├── .github ├── auth-discontinued.png ├── logo-rounded.png └── website-screenshot.png ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── client ├── colors.css ├── components │ ├── Button.css │ ├── Button.js │ ├── DashboardCreditCard.css │ ├── DashboardCreditCard.js │ ├── DashboardSection.css │ ├── DashboardSection.js │ ├── DashboardSubscription.css │ ├── DashboardSubscription.js │ ├── DashboardSubscriptionModal.css │ ├── DashboardSubscriptionModal.js │ ├── DashboardTokens.css │ ├── DashboardTokens.js │ ├── DashboardTokensModal.css │ ├── DashboardTokensModal.js │ ├── HomeTerminal.css │ ├── HomeTerminal.js │ ├── Modal.css │ ├── Modal.js │ ├── PageHead.css │ ├── PageHead.js │ ├── Snackbar.css │ ├── Snackbar.js │ ├── Spinner.css │ ├── Spinner.js │ ├── StripeCheckoutButton.css │ ├── StripeCheckoutButton.js │ ├── TextInput.css │ ├── TextInput.js │ ├── Toolbar.css │ └── Toolbar.js ├── config │ ├── keys.js │ └── strings.js ├── favicons │ ├── apple-touch-icon-114x114.png │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-144x144.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-57x57.png │ ├── apple-touch-icon-60x60.png │ ├── apple-touch-icon-72x72.png │ ├── apple-touch-icon-76x76.png │ ├── code.txt │ ├── favicon-128.png │ ├── favicon-16x16.png │ ├── favicon-196x196.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ └── mstile-70x70.png ├── globals.css ├── next.config.js ├── pages │ ├── Dashboard.css │ ├── Dashboard.js │ ├── Home.css │ └── Home.js ├── postcss.config.js ├── services │ ├── analytics.js │ ├── auth.js │ └── backend.js ├── static │ ├── favicons │ │ ├── apple-touch-icon-152x152.png │ │ ├── favicon-128.png │ │ ├── favicon-16x16.png │ │ ├── favicon-196x196.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── favicon.ico │ │ └── mstile-144x144.png │ ├── ic_arrow_back_white_24px.svg │ ├── ic_delete_white_24px.svg │ ├── logo-rounded.png │ ├── logo-rounded.webp │ ├── logo.png │ ├── logo.webp │ ├── logo@2x.png │ ├── logo@2x.webp │ ├── manifest.json │ ├── robots-development.txt │ ├── robots-production.txt │ ├── secret-blur.png │ ├── secret-blur.webp │ ├── sitemap.xml │ └── website-screenshot.png ├── types.js └── utils │ └── index.js ├── docker-compose.yml ├── package.json ├── server ├── __tests__ │ └── utils.js ├── app.js ├── config │ └── keys.js ├── index.js ├── middlewares │ ├── allowCrossDomain.js │ ├── auth0TokenGenerator.js │ ├── checkApiToken.js │ ├── checkAuthToken.js │ ├── checkMaxRequests.js │ ├── checkStripeCustomer.js │ ├── errorHandler.js │ ├── fetchUserFromAuth0.js │ ├── getIpAddress.js │ ├── rateLimiter.js │ └── requestLogger.js ├── routes │ ├── api.js │ └── user.js ├── services │ ├── auth0.js │ ├── redis.js │ ├── sentry.js │ └── stripe.js ├── static │ └── countries.json └── utils │ ├── common.js │ └── logger.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | [ 7 | "wrap-in-js", 8 | { 9 | "extensions": [ 10 | "css$" 11 | ] 12 | } 13 | ] 14 | ], 15 | "env": { 16 | "development": { 17 | "plugins": [ 18 | [ 19 | "inline-dotenv", 20 | { 21 | "path": ".env.client" 22 | } 23 | ] 24 | ] 25 | }, 26 | "production": { 27 | "plugins": [ 28 | [ 29 | "transform-inline-environment-variables", 30 | { 31 | "include": [ 32 | "REACT_APP_NODE_ENV", 33 | "REACT_APP_AUTH0_DOMAIN", 34 | "REACT_APP_AUTH0_AUDIENCE", 35 | "REACT_APP_AUTH0_CLIENT_ID", 36 | "REACT_APP_DOCS_URL", 37 | "REACT_APP_STRIPE_PUBLIC_KEY", 38 | "REACT_APP_STRIPE_FREE_PLAN_ID", 39 | "REACT_APP_STRIPE_PRO_PLAN_ID", 40 | "REACT_APP_STRIPE_PRO_PLAN_AMOUNT", 41 | "REACT_APP_MAX_API_TOKENS_PER_USER", 42 | "REACT_APP_RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS", 43 | "REACT_APP_RATE_LIMIT_FOR_FREE_USER_REQUESTS", 44 | "REACT_APP_RATE_LIMIT_FOR_PRO_USER_REQUESTS", 45 | "REACT_APP_EMAIL_ADDRESS", 46 | "REACT_APP_GOOGLE_ANALYTICS_TRACKING_ID", 47 | "REACT_APP_GOOGLE_SITE_VERIFICATION" 48 | ] 49 | } 50 | ] 51 | ] 52 | }, 53 | "test": { 54 | "presets": [ 55 | ["env", { "modules": "commonjs" }], 56 | "next/babel" 57 | ] 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /.docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | redis: 5 | image: redis:alpine 6 | ports: 7 | - "6379" 8 | web: 9 | build: . 10 | volumes: 11 | - ./:/app 12 | ports: 13 | - "1337:80" 14 | depends_on: 15 | - redis -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | README.md 4 | docker-compose.yml 5 | node_modules 6 | -------------------------------------------------------------------------------- /.env.client.example: -------------------------------------------------------------------------------- 1 | REACT_APP_AUTH0_AUDIENCE=https://superapp.com 2 | REACT_APP_AUTH0_CLIENT_ID=yourAuth0SuperAppClientId 3 | REACT_APP_AUTH0_DOMAIN=superapp-dev.eu.auth0.com 4 | REACT_APP_RATE_LIMIT_FOR_FREE_USER_REQUESTS=1000 5 | REACT_APP_RATE_LIMIT_FOR_PRO_USER_REQUESTS=10000 6 | REACT_APP_RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS=100 7 | REACT_APP_STRIPE_FREE_PLAN_ID=superapp.com-free 8 | REACT_APP_STRIPE_PRO_PLAN_AMOUNT=399 9 | REACT_APP_STRIPE_PRO_PLAN_ID=superapp.com-pro 10 | REACT_APP_STRIPE_PUBLIC_KEY=yourStripeSuperAppPublishableKey 11 | REACT_APP_MAX_API_TOKENS_PER_USER=5 -------------------------------------------------------------------------------- /.env.server.example: -------------------------------------------------------------------------------- 1 | AUTH0_AUDIENCE=https://superapp.com 2 | AUTH0_DOMAIN=superapp-dev.eu.auth0.com 3 | AUTH0_ISSUER=https://superapp-dev.eu.auth0.com/ 4 | AUTH0_JWKS_URI=https://superapp-dev.eu.auth0.com/.well-known/jwks.json 5 | AUTH0_MANAGEMENT_API_AUDIENCE=https://superapp-dev.eu.auth0.com/api/v2/ 6 | AUTH0_MANAGEMENT_API_CLIENT_ID=yourSuperappAuth0ManagementApiClientId 7 | AUTH0_MANAGEMENT_API_CLIENT_SECRET=yourSuperappAuth0ManagementApiClientSecret 8 | EXECUTION_ENV=development 9 | RATE_LIMIT_FOR_FREE_USER_REQUESTS=1000 10 | RATE_LIMIT_FOR_PRO_USER_REQUESTS=10000 11 | RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS=100 12 | RATE_LIMIT_WINDOW_IN_MS=3600000 13 | REDIS_URL=redis 14 | STRIPE_FREE_PLAN_ID=superapp.com-free 15 | STRIPE_PRO_PLAN_ID=superapp.com-pro 16 | STRIPE_PUBLIC_KEY=yourStripeSuperAppPublishableKey 17 | STRIPE_SECRET_KEY=yourStripeSuperAppSecretKey 18 | PORT=1337 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:react-app/recommended" 4 | ] 5 | } -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/babel-plugin-transform-react-remove-prop-types/.* 3 | 4 | [include] 5 | 6 | [libs] 7 | 8 | [options] 9 | -------------------------------------------------------------------------------- /.github/auth-discontinued.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/.github/auth-discontinued.png -------------------------------------------------------------------------------- /.github/logo-rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/.github/logo-rounded.png -------------------------------------------------------------------------------- /.github/website-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/.github/website-screenshot.png -------------------------------------------------------------------------------- /.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 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | .env 19 | .env.server 20 | .env.client 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | client/.next 27 | 28 | dump.rdb 29 | 30 | # DOCS 31 | *.gem 32 | *.rbc 33 | .bundle 34 | .config 35 | coverage 36 | InstalledFiles 37 | lib/bundler/man 38 | pkg 39 | rdoc 40 | spec/reports 41 | test/tmp 42 | test/version_tmp 43 | tmp 44 | *.DS_STORE 45 | build/ 46 | .cache 47 | .vagrant 48 | .sass-cache 49 | 50 | # YARD artifacts 51 | .yardoc 52 | _yardoc 53 | doc/ 54 | .idea/ 55 | 56 | .bundle 57 | .gems 58 | 59 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-alpine 2 | 3 | RUN mkdir /app 4 | # Set the working directory to /app 5 | WORKDIR /app 6 | COPY package.json /app 7 | RUN npm i -g yarn 8 | RUN yarn 9 | EXPOSE 1667 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |   4 | 5 | # NumValidate 6 | [![Open Source Love](https://badges.frapsoft.com/os/v1/open-source.svg?v=103)](https://github.com/ellerbrock/open-source-badges/) 7 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 8 | 9 |   10 | 11 | NumValidate is a phone validation REST API powered by Google LibPhoneNumber, a phone number formatting and parsing library released by Google, originally developed for (and currently used in) Google's Android mobile phone operating system, which uses several rigorous rules for parsing, formatting, and validating phone numbers for all countries/regions of the world. 12 | 13 |   14 | 15 |

16 | 17 | Numvalidate 18 | 19 |

20 |

21 | numvalidate.com 22 |

23 | 24 | ## Disclaimer 25 | 26 | NumValidate is currently out of service becacuse in December 2017 Auth0 has stopped supporting the core API authorization mechanism used by the app (querying for users by `app_metadata`). 27 | 28 | 29 | 30 | From my understanding the Auth0 team is not planning to re-introduce this feature anymore. 31 | 32 | ## Overview 33 | 34 | In this repository you'll be able to find everything you'll need to setup your own NumValidate platform. 35 | 36 | Even if you're not interested in phone number validation, I suggest you to take a look around here, since **you can easily customize NumValidate to expose any kind of API you like**: the phone validation APIs consists in [> 200 line of codes](https://github.com/mmazzarolo/numvalidate/blob/master/server/routes/api.js), while the remaining code supports the authentication, authorization, plan subscription, plan payment and API tokens management. 37 | 38 | ### Features 39 | - Plain simple phone number validation rest API, powered by [Google LibPhoneNumber](https://github.com/googlei18n/libphonenumber) and [documented](https://github.com/mmazzarolo/numvalidate-docs) with [Slate](https://github.com/lord/slate) 40 | - Server-side rendered responsive React website/landing-page (~160kb GZipped) 41 | - Private API tokens generation and management for authenticated users through the Dashboard 42 | - Fully featured authentication for accessing the Dashboard: email + password, Github and Google login thanks to [Auth0](https://auth0.com/) 43 | - API requests with different rate limits for unauthenticated user, free user and pro user, updated in real time after a subscription change 44 | - Secure payment for paid subscriptions handled by [Stripe](https://stripe.com): change your payment method at any given time 45 | - API tokens cached with Redis for faster response time on consecutive requests 46 | - Production ready logging and error reporting using Winston, [Sentry](https://sentry.io) and [Papertrail](https://papertrailapp.com/) 47 | 48 | ### Stack 49 | - Node.js - (Web Server) 50 | - React - (Website and dashboard UI) 51 | - Next - (Routing, Server Side Rendering and code splitting) 52 | - Koa - (Web App Server) 53 | - Redis - (Caching) 54 | - Flow - (Static Types in the Dashboard) 55 | - ESLint - (JS Best Practices/Code Highlighting) 56 | 57 | ### External services and platforms 58 | - [Auth0](https://auth0.com/) - (Authentication and authorization) 59 | - [Stripe](https://stripe.com) - (Payment processing) 60 | - [Papertrail](https://papertrailapp.com/) - (Log management) 61 | - [Sentry](https://sentry.io) - (Error tracking and reporting) 62 | 63 | ## Architecture 64 | 65 | ### The Client 66 | The client is a React application that exposes the **Home page** and the **Dashboard**: both pages are rendered server-side thanks to Next. 67 | To take advantage of Next server-side rendering, the app follows the convention of grouping the main routes under the `/pages` directory and putting all the static assets under `statics`. 68 | 69 | The app itself is not complex at all: the *Home page* is just a simple page that emphasizes the product features, while the *Dashboard* (available after a successfull signup) is used for generating API tokens and updating the user subscription plan. 70 | 71 | I tried to mimic the structure promoted by `create-react-app` as much as possible (since I love it for smaller sites), so I use plain CSS to style the components (with a touch of CSS just for supporting CSS Custom Variables for color names) and I don't use bleeding edge stuff like decorators et similia. 72 | For the same reason, you won't find any state management library here, since `setState` has proven to be more than than enough for this project. 73 | 74 | ```javascript 75 | client 76 | ├── components // The building blocks of the UI 77 | │ ├── Button.css 78 | │ ├── Button.js 79 | │ ├── DashboardCreditCard.css 80 | │ ├── DashboardCreditCard.js 81 | │ └── ... 82 | │ 83 | ├── config 84 | │ ├── keys.js // Constants used across the app (mostly are env vars) 85 | │ └── strings.js // Almost every string used in the Dashboard 86 | │ 87 | ├── pages // The actual pages (aka containers) server by Next 88 | │ ├── Dashboard.css 89 | │ ├── Dashboard.js 90 | │ ├── Home.css 91 | │ └── Home.js 92 | │ 93 | ├── services 94 | │ ├── analytics.js // Simple wrapper over Google Analytics 95 | │ ├── auth.js // Auth0 APIs 96 | │ └── backend.js // Backend (server) APIs 97 | │ 98 | ├── static // Static assets (favicons, images, robots.txt) 99 | │ 100 | ├── utils // Common utils used in all the app 101 | │ 102 | ├── colors.css // CSS colors variables 103 | │ 104 | ├── globals.css // Global styles 105 | │ 106 | ├── next.config // Babel config injected into Next 107 | │ 108 | ├── postcss.config // PostCSS config injected into Next 109 | │ 110 | └── types.js // Flowtype type definitions 111 | ``` 112 | 113 | ### The Server 114 | The server is a Node application powered by Koa, responsible of handling all the API requests and rendering the website/dashboard. 115 | There is no database here: all the users info like the API tokens and the Stripe customer ID are stored in Auth0 in the user `appMetadata`. The endpoints defined in `routes/user.js` handles all the requests made by the Dashboard to manage the user info. 116 | To fetch and update the users info from Auth0 and validate the user JWT I use an [Auth0 API](https://auth0.com/docs/api/info) (defined in `services/auth0.js`) with granted permissions to many [Auth0 management API endpoints](https://auth0.com/docs/api/management/v2). 117 | The requests to the `/api` endpoints are rate limited by IP (for unauthenticated users) and by API token (for authenticated users). 118 | 119 | The most interesting part of the server is probably the API token rate limiting and caching, which grants a fast response time on consecutive requests. The flow is the following: 120 | - An authenticated user makes a request to an `/api` endpoint with an `x-api-token` header. 121 | - The API token is validated in `middlewares/checkApiToken.js`: 122 | - If the API token is not cached in Redis then the server searches for an user with that API token in Auth0 to check for its validity (and cache it) 123 | - If the API token is already cached in Redis, the Auth0 search is skipped 124 | - The API token user's daily usage limit is checked in `middlewares/checkMaxRequests.js`: 125 | - If the daily usage limit of the user is not cached in Redis then the server searches for it (and cache it) by fetching the user subscription plan on Stripe 126 | - If the daily usage limit of the user is cached in Redis, the Stripe fetch is skipped 127 | - If the user reached its daily API requests limit then the server doesn't finalize the call to the `/api` endpoint and returns a `429` status code instead (`middlewares/rateLimited.js`). 128 | 129 | The Redis cache expires after the milliseconds defined in the `REDIS_CACHE_EXPIRY_IN_MS` environment variable and after an user subscription plan change. 130 | 131 | ```javascript 132 | server 133 | ├── config 134 | │ ├── keys.js // Constants used across the app (mostly are env vars) 135 | │ └── strings.js // Almost every string used in the Dashboard 136 | │ 137 | ├── middlewares 138 | │ ├── allowCrossDomain.js // CORS setup 139 | │ ├── auth0TokenGenerator.js // Daily Auth0 Management API token generator 140 | │ ├── checkApiToken.js // Validates the request API token 141 | │ ├── checkAuthToken.js // Validates the request Auth0 JWT 142 | │ ├── checkMaxRequests.js // Checks the max requests limit of the user 143 | │ ├── checkStripeCustomer.js // Verifies that the user initialized in Stripe 144 | │ ├── errorHandler.js // Returns a clean response error 145 | │ ├── fetchUserFromAuth0.js // Given the Auth0 JWT gets the user from Auth0 146 | │ ├── getIpAddress.js // Gets the request IP address 147 | │ ├── rateLimiter.js // Blocks the request on max requests limit reached 148 | │ └── requestLogger.js // Logs the request on console/Papertrail/Sentry 149 | │ 150 | ├── routes 151 | │ ├── api.js // Phone validation endpoints 152 | │ └── user.js // Dashboard endpoints 153 | │ 154 | ├── services 155 | │ ├── auth0.js // Auth0 APIs wrapper 156 | │ ├── redis.js // Redis queries 157 | │ ├── sentry.js // Sentry APIs wrapper 158 | │ └── stripe.js // Stripe APIs wrapper 159 | │ 160 | ├── static 161 | │ └── countries.json // Phone validation supported countries 162 | │ 163 | ├── utils 164 | │ ├── common.js // Common utils used in all the app 165 | │ └── logger.js // Winston logger setup 166 | │ 167 | ├── utils // Common utils used in all the app 168 | │ 169 | ├── app.js // App setup 170 | │ 171 | └── index.j // App entry point 172 | ``` 173 | 174 | ## How To Start The Application 175 | 176 | ### Auth0 Setup 177 | To run this project you'll need an [Auth0](https://auth0.com/) account. 178 | Since this is a complex process, I'll detail it by using the naming convention I followed with NumValidate, by supposing that your app name is **"SuperApp"** 179 | 180 | Please make sure all the items in the following checklist are marked before running this project in development: 181 | 182 | - [ ] Create an account on [Auth0](https://auth0.com/) and head to the Auth0 [dashboard](https://manage.auth0.com/) 183 | - [ ] Create a new tenant (which basically is a sub-account) that you'll use for development (by clicking on your icon in the top right corner and selecting **Create tenant**) and name it **"superapp-dev"** 184 | - [ ] Create a new Single Page Application Client named **"SuperApp"**: it will be the used to signup/login users in Auth0 185 | - [ ] In the created client detail, add to the **Allowed Callback URLs** the URL you'll redirect the user after a succesfull login 186 | - [ ] Create a new API named **"SuperApp"**: it will be used to authenticate and authorize the Auth0 user to your server (by checking their JWT) 187 | - [ ] Create a new client named **"SuperApp Management API Client"**: it will be used for calling the Auth0 Management APIs for fetching and updating user informations 188 | - [ ] In Auth0 Management API details and in **Non Interactive Clients** enable your **Auths Management API client** 189 | - [ ] Super boring stuff ahead: since some essential permissions are not enabled by default on the **SuperApp** client, you'll need to [add them manually by making an API call to the Auth0 Management API](https://community.auth0.com/questions/3944/error-grant-type-password-not-allowed-for-the-clie) 190 | 191 | 192 | If you're ready for production, you'll need to replicate all the above stuff in a new tenant (named **superapp**) and also check the following: 193 | 194 | - [ ] If you use any social integration (Google, Facebook, etc...) you'll need to provide your own API token/secrets for that integration 195 | - [ ] Setup your own email service (Amazon Ses, Mandrill, etc...) for sending the Auth0 emails 196 | - [ ] Customize the email templates to better suit your needs 197 | 198 | I also suggest adding a custom rule for locking the user out of your app until it has not verified its email (it will still be able to access the app for the first day post-signup): 199 | ```javascript 200 | function (user, context, callback) { 201 | var oneDayPostSignup = new Date(user.created_at).getTime() + (24 * 60 * 60 * 1000); 202 | if (!user.email_verified && new Date().getTime() > oneDayPostSignup) { 203 | return callback(new UnauthorizedError('Please verify your email before logging in.')); 204 | } else { 205 | return callback(null, user, context); 206 | } 207 | } 208 | ``` 209 | 210 | ### Redis Setup 211 | To run this project you'll need a running Redis instance. 212 | 213 | ### Stripe Setup 214 | To run this project you'll need a [Stripe](https://stripe.com) account. 215 | 216 | Please make sure all the items in the following checklist are marked before running this project in development: 217 | 218 | - [ ] Create an account on [Stripe](https://stripe.com) and head to the Stripe [dashboard](https://dashboard.stripe.com/dashboard) 219 | - [ ] Switch to the **test mode** by toggling the **View test data** button in the left sidebar 220 | - [ ] Subscription -> Plans -> Create a new plan with a price of 0.00€/$: it will be your app free plan 221 | - [ ] Subscription -> Plans -> Create a new plan with a price you like: it will be your app paid plan 222 | 223 | If you're ready for production, you'll need to create the above subscription outside of the **test mode** too, and verify your business settings. 224 | 225 | ### Setup 226 | 227 | Run the app in dev mode (including *hot module reloading*) with: 228 | 229 | ```bash 230 | npm install 231 | npm run start-dev 232 | ``` 233 | 234 | Or, if you prefer using yarn, run the app with: 235 | 236 | ```bash 237 | yarn 238 | yarn start-dev 239 | ``` 240 | 241 | To run in production mode: 242 | 243 | `npm run build && npm start` 244 | 245 | Or, with yarn: 246 | 247 | `yarn build && yarn start` 248 | 249 | ### Setup with Docker Compose 250 | 251 | This project supports [docker-compose](https://docs.docker.com/compose/). 252 | To run it, you must first: 253 | - rename [.env.client.example](https://github.com/mmazzarolo/numvalidate/blob/master/client/.env.client.example) to .env.client 254 | - rename [.env.server.example](https://github.com/mmazzarolo/numvalidate/blob/master/server/.env.server.example) to .env.server 255 | - run `docker-compose up -d` from the root folder of this repo 256 | 257 | If you want to check the output of a certain container, just run `docker-compose logs $CONTAINER_NAME`. 258 | `$CONTAINER_NAME` may be one of the following: 259 | - `redis` 260 | - `web` 261 | 262 | For additional infos, please check out [docker-compose.yml](https://github.com/mmazzarolo/numvalidate/blob/master/docker-compose.yml). 263 | 264 | ### Configuration 265 | 266 | This project makes an heavy use of environment variables for its configuration, so, if you want to run the project locally, you are adviced to include a `.env.server` and a `env.client` file in your project root (I use two dotenv files instead of one to keep the things clearer while developing). 267 | 268 | Client environment variables: (check [.env.client.example](https://github.com/mmazzarolo/numvalidate/blob/master/client/.env.client.example)) 269 | 270 | | Environment Variable | Default | Description | 271 | | ------------- | ------------- | ------------- | 272 | | `REACT_APP_AUTH0_AUDIENCE` | *REQUIRED* | Auth0 audience | 273 | | `REACT_APP_AUTH0_CLIENT_ID` | *REQUIRED* | Auth0 ClientID | 274 | | `REACT_APP_AUTH0_DOMAIN` | *REQUIRED* | Auth0 domain | 275 | | `REACT_APP_RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS` | 100 | Rate limit for unauthenticated users | 276 | | `REACT_APP_RATE_LIMIT_FOR_FREE_USER_REQUESTS` | 1000 | Rate limit for free users | 277 | | `REACT_APP_RATE_LIMIT_FOR_PRO_USER_REQUESTS` | 100000 | Rate limit for pro users | 278 | | `REACT_APP_STRIPE_FREE_PLAN_ID` | *REQUIRED* | Free plan ID in Stripe | 279 | | `REACT_APP_STRIPE_PRO_PLAN_ID` | *REQUIRED* | Pro plan ID in Stripe | 280 | | `REACT_APP_STRIPE_PRO_PLAN_AMOUNT` | 399 | The pro plan subscription amount in Stripe | 281 | | `REACT_APP_STRIPE_PUBLIC_KEY` | *REQUIRED* | Stripe API public key | 282 | | `REACT_APP_MAX_API_TOKENS_PER_USER` | 5 | The maximum number of API tokens per user | 283 | | `REACT_APP_GOOGLE_SITE_VERIFICATION` | OPTIONAL | The Google Search Console verification key | 284 | 285 | Server environment variables: (check [.env.server.example](https://github.com/mmazzarolo/numvalidate/blob/master/server/.env.server.example)) 286 | 287 | | Environment Variable | Default | Description | 288 | | ------------- | ------------- | ------------- | 289 | | `PORT` | 1337| The port where the server will run | 290 | | `AUTH0_AUDIENCE` | *REQUIRED* | Auth0 audience | 291 | | `AUTH0_DOMAIN` | *REQUIRED* | Auth0 audience | 292 | | `AUTH0_ISSUER` | *REQUIRED* | Auth0 audience | 293 | | `AUTH0_JWKS_URI` | *REQUIRED* | Auth0 audience | 294 | | `AUTH0_MANAGEMENT_API_AUDIENCE` | *REQUIRED* | Auth0 audience | 295 | | `AUTH0_MANAGEMENT_API_CLIENT_ID` | *REQUIRED* | Auth0 audience | 296 | | `AUTH0_MANAGEMENT_API_CLIENT_SECRET` | *REQUIRED* | Auth0 audience | 297 | | `EXECUTION_ENV` | development | Used mainly for logging infos | 298 | | `RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS` | 100 | Rate limit for unauthenticated users | 299 | | `RATE_LIMIT_FOR_FREE_USER_REQUESTS` | 1000 | Rate limit for free users | 300 | | `RATE_LIMIT_FOR_PRO_USER_REQUESTS` | 100000 | Rate limit for pro users | 301 | | `REDIS_URL` | *REQUIRED* | Redis URL | 302 | | `REDIS_CACHE_EXPIRY_IN_MS` | ms('1d') | Expiration of Redis cache in milliseconds | 303 | | `STRIPE_FREE_PLAN_ID` | *REQUIRED* | Free plan ID in Stripe | 304 | | `STRIPE_PRO_PLAN_ID` | *REQUIRED* | Pro plan ID in Stripe | 305 | | `STRIPE_PUBLIC_KEY` | *REQUIRED* | Stripe API public key | 306 | | `STRIPE_SECRET_KEY` | *REQUIRED* | Stripe API secret key | 307 | | `PAPERTRAIL_HOST` | *OPTIONAL* | Papertrail URL | 308 | | `PAPERTRAIL_PORT` | *OPTIONAL* | Papertrail port | 309 | | `SENTRY_DSN` | *OPTIONAL* | Sentry DSN | 310 | 311 | ## External Related Issues And Pull Requests 312 | - zeit/next.js - [Added side note about enabling gzip on Koa](https://github.com/zeit/next.js/pull/2867) 313 | - auth0/auth0.js - [Nonce does not match](https://github.com/auth0/auth0.js/issues/365) 314 | - auth0/auth0.js - [Auth0 API it not instantly synced to Auth0 data](https://github.com/auth0/auth0.js/issues/515) 315 | - auth0/node-auth0 - [Recommended way to re-inject renewed token for Management API in a long-running script?](https://github.com/auth0/node-auth0/issues/164) 316 | 317 | 318 | ## Contributing 319 | Pull requests are welcome. File an issue for ideas, conversation or feedback. 320 | 321 | -------------------------------------------------------------------------------- /client/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-primary: #fff; 3 | --color-accent: #fff; 4 | --color-background: #21272f; 5 | --color-error: #d64242; 6 | --color-text-1: #fff; 7 | --color-text-2: rgba(255, 255, 255, 0.5); 8 | --color-grey-dark: rgba(255, 255, 255, 0.1); 9 | --color-grey-light: rgba(255, 255, 255, 0.5); 10 | --color-grey-lighter: rgba(255, 255, 255, 0.6); 11 | --color-grey-lightest: rgba(255, 255, 255, 0.8); 12 | } -------------------------------------------------------------------------------- /client/components/Button.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .Button { 4 | background: transparent; 5 | padding: 8px; 6 | text-transform: uppercase; 7 | text-align: center; 8 | border-radius: 3px; 9 | cursor: pointer; 10 | font-size: 14px; 11 | font-weight: 500; 12 | min-width: 100px; 13 | transition: all 0.25s ease-in-out; 14 | } 15 | 16 | .Button:hover:enabled { 17 | color: var(--color-background); 18 | background: var(--color-primary); 19 | } 20 | 21 | .Button:disabled { 22 | cursor: initial; 23 | color: var(--color-grey-dark); 24 | border: 2px solid var(--color-grey-dark); 25 | } 26 | 27 | .Button-default { 28 | color: var(--color-grey-lightest); 29 | border: 2px solid var(--color-grey-lightest); 30 | } 31 | 32 | .Button-info { 33 | padding: 6px; 34 | font-size: 12px; 35 | color: var(--color-grey-lightest); 36 | border: 2px solid var(--color-grey-lightest); 37 | } 38 | 39 | .Button-medium { 40 | } 41 | 42 | .Button-big { 43 | font-weight: bold; 44 | padding: 14px 42px; 45 | } 46 | 47 | .Button-big:hover:enabled, 48 | .Button-big:focus { 49 | transform: scale(1.05); 50 | } 51 | 52 | .Button-flat { 53 | font-weight: bold; 54 | border: none; 55 | } 56 | 57 | .Button-warn { 58 | color: #fdbc72; 59 | } 60 | 61 | .Button-danger { 62 | color: #c23d4b; 63 | } 64 | 65 | .Button-fill { 66 | width: 100%; 67 | } -------------------------------------------------------------------------------- /client/components/Button.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import style from './Button.css'; 4 | 5 | type Props = { 6 | type?: 'default' | 'info' | 'warn' | 'danger', 7 | size?: 'medium' | 'big', 8 | fill?: boolean, 9 | flat?: boolean, 10 | children?: any, 11 | }; 12 | 13 | class Button extends Component { 14 | render() { 15 | const { type = 'default', size = 'medium', flat, fill, children, ...otherProps } = this.props; 16 | const typeClassName = `Button-${type}`; 17 | const sizeClassName = `Button-${size}`; 18 | const fillClassName = fill ? 'Button-fill' : ''; 19 | const flatClassName = flat ? 'Button-flat' : ''; 20 | return ( 21 | 28 | ); 29 | } 30 | } 31 | 32 | export default Button; 33 | -------------------------------------------------------------------------------- /client/components/DashboardCreditCard.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .PaymentInfo-header { 4 | display: flex; 5 | flex-direction: row; 6 | justify-content: space-between; 7 | transition: background-color .15s ease 0s; 8 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 9 | font-size: 13px; 10 | font-weight: 500; 11 | color: var(--color-primary); 12 | text-transform: uppercase; 13 | } 14 | 15 | .PaymentInfo-header-cell { 16 | display: flex; 17 | padding: 6px 20px; 18 | } 19 | 20 | .PaymentInfo-content { 21 | display: flex; 22 | flex-direction: row; 23 | align-items: center; 24 | justify-content: space-between; 25 | transition: background-color .15s ease 0s; 26 | font-weight: 400; 27 | color: var(--color-text-1); 28 | } 29 | 30 | .PaymentInfo-content-cell { 31 | display: flex; 32 | align-items: center; 33 | padding: 12px 20px; 34 | } 35 | -------------------------------------------------------------------------------- /client/components/DashboardCreditCard.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import format from 'date-fns/format'; 4 | import DashboardSection from './DashboardSection'; 5 | import StripeCheckoutButton from './StripeCheckoutButton'; 6 | import backendService from '../services/backend'; 7 | import analyticsService from '../services/analytics'; 8 | import strings from '../config/strings'; 9 | import style from './DashboardCreditCard.css'; 10 | 11 | import type { CreditCard, User } from '../types'; 12 | 13 | type Props = { 14 | userEmail: string, 15 | userCreditCard: CreditCard, 16 | onPaymentInfoUpdateSuccess: (user: User) => mixed, 17 | onPaymentInfoUpdateFailure: (error: Error) => mixed, 18 | }; 19 | 20 | type State = { 21 | updatingPaymentInfo: boolean, 22 | }; 23 | 24 | class DashboardCreditCard extends Component { 25 | state = { 26 | updatingPaymentInfo: false, 27 | }; 28 | 29 | _updatePaymentInfo = async (token: string) => { 30 | this.setState({ updatingPaymentInfo: true }); 31 | try { 32 | const updatedUser = await backendService.updateUserPaymentInfo(token); 33 | analyticsService.event({ 34 | category: 'Payment', 35 | action: 'Updated credit card details', 36 | }); 37 | this.props.onPaymentInfoUpdateSuccess(updatedUser); 38 | } catch (err) { 39 | this.props.onPaymentInfoUpdateFailure(err); 40 | } finally { 41 | this.setState({ updatingPaymentInfo: false }); 42 | } 43 | }; 44 | 45 | render() { 46 | const { userEmail, userCreditCard = {} } = this.props; 47 | return ( 48 | 59 | } 60 | loading={this.state.updatingPaymentInfo} 61 | > 62 | 63 |
64 |
65 | {strings.PAYMENT_INFO_TABLE_HEADER_CARD} 66 |
67 |
68 | {strings.PAYMENT_INFO_TABLE_HEADER_UPDATED} 69 |
70 |
71 | {!this.props.loading && ( 72 |
73 |
74 |
75 | {`${userCreditCard.expMonth}/${userCreditCard.expYear} ${userCreditCard.last4}`} 76 |
77 |
78 |
79 | {format(userCreditCard.createdAt || userCreditCard.updatedAt, 'MMM D, YYYY')} 80 |
81 |
82 | )} 83 |
84 | ); 85 | } 86 | } 87 | 88 | export default DashboardCreditCard; 89 | -------------------------------------------------------------------------------- /client/components/DashboardSection.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .DashboardSection { 4 | display: flex; 5 | flex-direction: column; 6 | margin-bottom: 40px; 7 | } 8 | 9 | @media only screen and (max-width: 767px) { 10 | .DashboardSection-header { 11 | } 12 | } 13 | 14 | .DashboardSection-header { 15 | display: flex; 16 | flex-direction: row; 17 | justify-content: space-between; 18 | } 19 | 20 | @media only screen and (max-width: 767px) { 21 | .DashboardSection-header { 22 | flex-direction: column; 23 | margin-bottom: 20px; 24 | } 25 | } 26 | 27 | .DashboardSection-header-left { 28 | display: flex; 29 | flex-direction: column; 30 | } 31 | 32 | @media only screen and (max-width: 767px) { 33 | .DashboardSection-header-left { 34 | margin-bottom: 20px; 35 | } 36 | } 37 | 38 | .DashboardSection-header-left > h1 { 39 | margin-bottom: 10px; 40 | } 41 | 42 | .DashboardSection-header-left > p { 43 | color: var(--color-text-2); 44 | font-size: 14px; 45 | font-weight: 400; 46 | margin-top: 0px; 47 | } 48 | 49 | .DashboardSection-header-right { 50 | display: flex; 51 | justify-content: center; 52 | align-items: center; 53 | } 54 | 55 | @media only screen and (max-width: 767px) { 56 | .DashboardSection-header-right { 57 | margin-bottom: 20px; 58 | } 59 | } -------------------------------------------------------------------------------- /client/components/DashboardSection.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import Spinner from './Spinner'; 4 | import Button from './Button'; 5 | import style from './DashboardSection.css'; 6 | 7 | type Props = { 8 | title: string, 9 | subtitle: string, 10 | rightElement?: any, 11 | rightButtonText?: string, 12 | rightButtonType?: string, 13 | onRightButtonClick?: () => mixed, 14 | loading?: boolean, 15 | children?: any, 16 | }; 17 | 18 | const DashboardSection = (props: Props) => { 19 | const { 20 | title, 21 | subtitle, 22 | rightElement, 23 | rightButtonText, 24 | onRightButtonClick, 25 | loading, 26 | children, 27 | } = props; 28 | let rightButton; 29 | if (rightElement) { 30 | rightButton = rightElement; 31 | } else if (rightButtonText && onRightButtonClick) { 32 | rightButton = ; 33 | } 34 | return ( 35 |
36 | 37 |
38 |
39 |

{title}

40 |

{subtitle}

41 |
42 |
43 | {!loading && rightButton} 44 | {loading && } 45 |
46 |
47 | {children} 48 |
49 | ); 50 | }; 51 | 52 | export default DashboardSection; 53 | -------------------------------------------------------------------------------- /client/components/DashboardSubscription.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .Subscription-header { 4 | display: flex; 5 | flex-direction: row; 6 | justify-content: space-between; 7 | transition: background-color .15s ease 0s; 8 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 9 | font-size: 14px; 10 | font-weight: 500; 11 | color: var(--color-primary); 12 | text-transform: uppercase; 13 | } 14 | 15 | @media only screen and (max-width: 767px) { 16 | .Subscription-header { 17 | display: none; 18 | } 19 | } 20 | 21 | .Subscription-header-cell { 22 | display: flex; 23 | padding: 6px 20px; 24 | } 25 | 26 | .Subscription-content { 27 | display: flex; 28 | flex-direction: row; 29 | align-items: center; 30 | justify-content: space-between; 31 | transition: background-color .15s ease 0s; 32 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 33 | font-weight: 400; 34 | color: var(--color-text-1); 35 | } 36 | 37 | @media only screen and (max-width: 767px) { 38 | .Subscription-content { 39 | flex-direction: column; 40 | font-size: 14px; 41 | margin-bottom: 30px; 42 | border-bottom: none; 43 | } 44 | } 45 | 46 | .Subscription-content-cell { 47 | display: flex; 48 | align-items: center; 49 | padding: 12px 20px; 50 | min-height: 60px; 51 | } 52 | 53 | @media only screen and (max-width: 767px) { 54 | .Subscription-content-cell { 55 | margin-bottom: 10px; 56 | } 57 | } 58 | 59 | .Subscription-content-cell-separator { 60 | margin-left: 4px; 61 | margin-right: 4px; 62 | } 63 | 64 | @media only screen and (max-width: 767px) { 65 | .Subscription-content-cell-separator { 66 | display: none; 67 | } 68 | } 69 | 70 | @media only screen and (max-width: 767px) { 71 | .Subscription-content-cell-amount { 72 | font-size: 24px; 73 | } 74 | } 75 | 76 | @media only screen and (max-width: 767px) { 77 | .Subscription-content-cell-description { 78 | font-size: 15px; 79 | text-align: center; 80 | } 81 | } 82 | 83 | .Subscription-content-cell > p { 84 | display: flex; 85 | align-items: center; 86 | margin: 0px; 87 | } 88 | 89 | @media only screen and (max-width: 767px) { 90 | .Subscription-content-cell p { 91 | display: flex; 92 | flex-direction: column; 93 | } 94 | } 95 | 96 | @media only screen and (max-width: 767px) { 97 | .Subscription-content-cell-current { 98 | font-size: 16px; 99 | font-style: italic; 100 | margin-top: 0px; 101 | opacity: 0.8; 102 | } 103 | } -------------------------------------------------------------------------------- /client/components/DashboardSubscription.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import Modal from './Modal'; 4 | import Button from './Button'; 5 | import DashboardSection from './DashboardSection'; 6 | import DashboardSubscriptionModal from './DashboardSubscriptionModal'; 7 | import backendService from '../services/backend'; 8 | import analyticsService from '../services/analytics'; 9 | import strings from '../config/strings'; 10 | import keys from '../config/keys'; 11 | import style from './DashboardSubscription.css'; 12 | 13 | import type { Subscription, User } from '../types'; 14 | 15 | type Props = { 16 | userSubscription?: Subscription, 17 | onPlanChangeSuccess: (updatedUser: User) => mixed, 18 | onPlanChangeFailure: (error: Error) => mixed, 19 | }; 20 | 21 | type State = { 22 | visibleModal: ?'FREE' | 'PRO', 23 | updatingSubscriptionPlan: boolean, 24 | }; 25 | 26 | class DashboardSubscription extends Component { 27 | state = { 28 | visibleModal: null, 29 | updatingSubscriptionPlan: false, 30 | }; 31 | 32 | _showModal = (modalType: 'FREE' | 'PRO') => { 33 | this.setState({ visibleModal: modalType }); 34 | }; 35 | 36 | _hideModal = () => { 37 | this.setState({ visibleModal: null }); 38 | }; 39 | 40 | _updateSubscriptionPlan = async (planId: string, token?: string) => { 41 | this.setState({ visibleModal: null, updatingSubscriptionPlan: true }); 42 | try { 43 | const updatedUser = await backendService.updateUserSubscription(planId, token); 44 | analyticsService.event({ 45 | category: 'Subscription', 46 | action: `Updated subscription to ${token ? 'pro' : 'free'} plan`, 47 | }); 48 | this.props.onPlanChangeSuccess(updatedUser); 49 | } catch (err) { 50 | this.props.onPlanChangeFailure(err); 51 | } finally { 52 | this.setState({ updatingSubscriptionPlan: false }); 53 | } 54 | }; 55 | 56 | render() { 57 | const { userSubscription = {} } = this.props; 58 | return ( 59 | 64 | 65 |
66 |
67 | {strings.SUBSCRIPTION_TABLE_HEADER_SUBSCRIPTION} 68 |
69 |
70 | 71 |
72 |
73 |
74 |
75 |
76 |

77 | 78 | {strings.PAYMENT_INFO_FREE_PLAN_AMOUNT} 79 | 80 | {' - '} 81 | 82 | {strings.PAYMENT_INFO_FREE_PLAN_DESCRIPTION} 83 | 84 |

85 |
86 | {userSubscription.planId === keys.STRIPE_FREE_PLAN_ID && ( 87 |

88 | {strings.SUBSCRIPTION_CURRENT_PLAN} 89 |

90 | )} 91 | {userSubscription.planId !== keys.STRIPE_FREE_PLAN_ID && ( 92 | 99 | )} 100 |
101 |
102 |
103 |

104 | 105 | {strings.PAYMENT_INFO_PRO_PLAN_AMOUNT} 106 | 107 | {' - '} 108 | 109 | {strings.PAYMENT_INFO_PRO_PLAN_DESCRIPTION} 110 | 111 |

112 |
113 | {userSubscription.planId === keys.STRIPE_PRO_PLAN_ID && ( 114 |

115 | {strings.SUBSCRIPTION_CURRENT_PLAN} 116 |

117 | )} 118 | {userSubscription.planId !== keys.STRIPE_PRO_PLAN_ID && ( 119 | 126 | )} 127 |
128 |
129 | {this.state.visibleModal === 'FREE' && ( 130 | 135 | this._updateSubscriptionPlan(keys.STRIPE_FREE_PLAN_ID || '')} 139 | /> 140 | 141 | )} 142 | {this.state.visibleModal === 'PRO' && ( 143 | 148 | 152 | this._updateSubscriptionPlan(keys.STRIPE_PRO_PLAN_ID || '', token)} 153 | amount={keys.STRIPE_PRO_PLAN_AMOUNT} 154 | /> 155 | 156 | )} 157 |
158 | ); 159 | } 160 | } 161 | 162 | export default DashboardSubscription; 163 | -------------------------------------------------------------------------------- /client/components/DashboardSubscriptionModal.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .SubscriptionChangeModal { 4 | width: 390px; 5 | color: white; 6 | } 7 | 8 | .SubscriptionChangeModal-footer { 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: flex-end; 12 | margin-top: 30px; 13 | } 14 | 15 | .SubscriptionChangeModal-button { 16 | margin-left: 30px; 17 | border: none; 18 | } 19 | 20 | @media only screen and (max-width: 767px) { 21 | .SubscriptionChangeModal { 22 | width: 100%; 23 | } 24 | } -------------------------------------------------------------------------------- /client/components/DashboardSubscriptionModal.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import Button from './Button'; 4 | import StripeCheckoutButton from './StripeCheckoutButton'; 5 | import strings from '../config/strings'; 6 | import style from './DashboardSubscriptionModal.css'; 7 | 8 | type Props = { 9 | text: string, 10 | amount?: number, 11 | onCancelClick: () => mixed, 12 | onConfirmClick?: () => mixed, 13 | onStripeTokenReceived?: (token: string) => mixed, 14 | }; 15 | 16 | class DashboardSubscriptionChangeModal extends Component { 17 | render() { 18 | const { text, amount, onCancelClick, onConfirmClick, onStripeTokenReceived } = this.props; 19 | return ( 20 |
21 | 22 |

{text}

23 |
24 |
25 | 28 |
29 |
30 | {amount && 31 | onStripeTokenReceived && ( 32 | 38 | )} 39 | {!amount && ( 40 | 43 | )} 44 |
45 |
46 |
47 | ); 48 | } 49 | } 50 | 51 | export default DashboardSubscriptionChangeModal; 52 | -------------------------------------------------------------------------------- /client/components/DashboardTokens.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .Tokens-header { 4 | display: flex; 5 | flex-direction: row; 6 | justify-content: space-between; 7 | transition: background-color .15s ease 0s; 8 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 9 | font-size: 14px; 10 | font-weight: 500; 11 | color: var(--color-primary); 12 | text-transform: uppercase; 13 | } 14 | 15 | @media only screen and (max-width: 767px) { 16 | .Tokens-header { 17 | display: none; 18 | } 19 | } 20 | 21 | .Tokens-header-cell-name { 22 | display: flex; 23 | padding: 6px 20px; 24 | flex: 1; 25 | overflow: hidden; 26 | } 27 | 28 | .Tokens-header-cell-token { 29 | display: flex; 30 | padding: 6px 20px; 31 | flex: 2; 32 | } 33 | 34 | .Tokens-header-cell-date { 35 | display: flex; 36 | padding: 6px 20px; 37 | flex: 1; 38 | } 39 | 40 | .Tokens-header-cell-delete { 41 | width: 20px; 42 | min-width: 20px; 43 | padding: 6px 20px; 44 | } 45 | 46 | .Tokens-content { 47 | display: flex; 48 | flex-direction: row; 49 | align-items: center; 50 | justify-content: space-between; 51 | transition: background-color .15s ease 0s; 52 | font-weight: 400; 53 | color: var(--color-text-1); 54 | } 55 | 56 | @media only screen and (max-width: 767px) { 57 | .Tokens-content { 58 | margin-bottom: 32px; 59 | } 60 | } 61 | 62 | .Tokens-content-left { 63 | display: flex; 64 | flex-direction: row; 65 | align-items: center; 66 | justify-content: space-between; 67 | width: 100%; 68 | } 69 | 70 | @media only screen and (max-width: 767px) { 71 | .Tokens-content-left { 72 | flex-direction: column; 73 | align-items: flex-start; 74 | } 75 | } 76 | 77 | .Tokens-content-cell-name { 78 | display: flex; 79 | padding: 12px 20px; 80 | flex: 1; 81 | overflow: hidden; 82 | } 83 | 84 | @media only screen and (max-width: 767px) { 85 | .Tokens-content-cell-name { 86 | font-size: 18px; 87 | padding: 12px 8px; 88 | } 89 | } 90 | 91 | .Tokens-content-cell-token { 92 | display: flex; 93 | padding: 12px 20px; 94 | font-weight: 600; 95 | flex: 2; 96 | } 97 | 98 | @media only screen and (max-width: 767px) { 99 | .Tokens-content-cell-token { 100 | font-size: 12px; 101 | text-align: left; 102 | padding: 12px 8px; 103 | justify-content: flex-start; 104 | width: 100%; 105 | } 106 | } 107 | 108 | .Tokens-content-cell-date { 109 | display: flex; 110 | padding: 12px 20px; 111 | flex: 1; 112 | } 113 | 114 | @media only screen and (max-width: 767px) { 115 | .Tokens-content-cell-date { 116 | display: none; 117 | } 118 | } 119 | 120 | .Tokens-content-cell-delete { 121 | width: 20px; 122 | cursor: pointer; 123 | overflow: hidden; 124 | padding: 6px 20px; 125 | display: flex; 126 | justify-content: center; 127 | align-items: center; 128 | transition: all 0.2s; 129 | } 130 | 131 | .Tokens-content-cell-delete:hover { 132 | transform: scale(1.1); 133 | opacity: 0.8; 134 | } 135 | 136 | .Tokens-content-cell-delete-disabled { 137 | cursor: initial; 138 | opacity: 0.4; 139 | transform: none; 140 | } 141 | 142 | .Tokens-content-token-hidden { 143 | display: flex; 144 | align-items: center; 145 | justify-content: center; 146 | height: 23px; 147 | width: 240px; 148 | background-repeat: no-repeat; 149 | background-size: 240px 23px; 150 | } 151 | 152 | .Tokens-content-token-visible { 153 | display: flex; 154 | align-items: center; 155 | font-family: Source Code Pro,Courier New,Courier,monospace; 156 | height: 23px; 157 | } 158 | 159 | .Tokens-modal-content { 160 | display: flex; 161 | justify-content: center; 162 | align-items: center; 163 | flex-direction: column; 164 | word-break: break-word; 165 | text-align: center; 166 | } 167 | 168 | .Tokens-modal-button { 169 | margin-top: 20px; 170 | margin-bottom: 10px; 171 | } -------------------------------------------------------------------------------- /client/components/DashboardTokens.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from "react"; 3 | import format from "date-fns/format"; 4 | import DashboardTokensModal from "./DashboardTokensModal"; 5 | import Button from "./Button"; 6 | import Modal from "./Modal"; 7 | import DashboardSection from "./DashboardSection"; 8 | import backendService from "../services/backend"; 9 | import analyticsService from "../services/analytics"; 10 | import strings from "../config/strings"; 11 | import keys from "../config/keys"; 12 | import style from "./DashboardTokens.css"; 13 | 14 | import type { ApiToken, User } from "../types"; 15 | 16 | type Props = { 17 | userApiTokens: ApiToken[], 18 | onGenerateApiTokenSuccess: (updatedUser: User) => mixed, 19 | onGenerateApiTokenFailure: (error: Error) => mixed, 20 | onDeleteApiTokenSuccess: (updatedUser: User) => mixed, 21 | onDeleteApiTokenFailure: (error: Error) => mixed 22 | }; 23 | 24 | type State = { 25 | visibleTokens: string[], 26 | modalVisible: boolean, 27 | generatingApiToken: boolean, 28 | deletingApiToken: boolean 29 | }; 30 | 31 | class DashboardTokens extends Component { 32 | state = { 33 | visibleTokens: [], 34 | modalVisible: false, 35 | generatingApiToken: false, 36 | deletingApiToken: false 37 | }; 38 | 39 | _showToken = (tokenValue: string) => { 40 | this.setState({ 41 | visibleTokens: [...this.state.visibleTokens, tokenValue] 42 | }); 43 | }; 44 | 45 | _toggleModal = () => { 46 | this.setState({ modalVisible: !this.state.modalVisible }); 47 | }; 48 | 49 | _generateToken = async (tokenName: string) => { 50 | this.setState({ generatingApiToken: true, modalVisible: false }); 51 | try { 52 | const updatedUser = await backendService.createUserApiToken(tokenName); 53 | this.props.onGenerateApiTokenSuccess(updatedUser); 54 | analyticsService.event({ 55 | category: "Tokens", 56 | action: "Created a token" 57 | }); 58 | } catch (err) { 59 | this.props.onGenerateApiTokenFailure(err); 60 | } finally { 61 | this.setState({ generatingApiToken: false }); 62 | } 63 | }; 64 | 65 | _deleteToken = async (tokenValue: string) => { 66 | if (!this.deletingApiToken) { 67 | this.setState({ deletingApiToken: true, modalVisible: false }); 68 | const updatedUser = await backendService.deleteUserApiToken(tokenValue); 69 | analyticsService.event({ 70 | category: "Tokens", 71 | action: "Deleted a token" 72 | }); 73 | this.setState({ deletingApiToken: false }); 74 | this.props.onDeleteApiTokenSuccess(updatedUser); 75 | } 76 | }; 77 | 78 | render() { 79 | const { userApiTokens = [] } = this.props; 80 | const apiTokensLimitReached = 81 | userApiTokens.length >= keys.MAX_API_TOKENS_PER_USER; 82 | return ( 83 | 94 | 95 |
96 |
97 | {strings.API_TOKEN_TABLE_HEADER_NAME} 98 |
99 |
100 | {strings.API_TOKEN_TABLE_HEADER_TOKEN} 101 |
102 |
103 | {strings.API_TOKEN_TABLE_HEADER_UPDATED} 104 |
105 |
106 | 107 |
108 |
109 | {!this.props.loading && 110 | userApiTokens.map((apiToken, index) => { 111 | const disableDelete = 112 | this.state.generatingApiToken || this.state.deletingApiToken; 113 | const visible = 114 | this.state.visibleTokens.indexOf(apiToken.value) !== -1; 115 | return ( 116 |
117 |
118 |
119 | {apiToken.name} 120 |
121 |
122 | {visible ? ( 123 |
124 | {apiToken.value} 125 |
126 | ) : ( 127 |
133 | 139 |
140 | )} 141 |
142 |
143 | {format(apiToken.createdAt, "MMM D, YYYY")} 144 |
145 |
146 |
151 | !disableDelete && this._deleteToken(apiToken.value)} 152 | > 153 | {"Delete"} 157 |
158 |
159 | ); 160 | })} 161 | 166 | 167 | 168 |
169 | ); 170 | } 171 | } 172 | 173 | export default DashboardTokens; 174 | -------------------------------------------------------------------------------- /client/components/DashboardTokensModal.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .TokensGenerator { 4 | width: 490px; 5 | } 6 | 7 | @media only screen and (max-width: 767px) { 8 | .TokensGenerator { 9 | width: 100%; 10 | } 11 | } 12 | 13 | .TokensGenerator-body { 14 | margin-top: 20px; 15 | margin-bottom: 10px; 16 | } 17 | 18 | .TokensGenerator-footer { 19 | margin-top: 20px; 20 | width: 100%; 21 | } -------------------------------------------------------------------------------- /client/components/DashboardTokensModal.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import Button from './Button'; 4 | import TextInput from './TextInput'; 5 | import strings from '../config/strings'; 6 | import style from './DashboardTokensModal.css'; 7 | 8 | type Props = { 9 | onConfirm: (tokenName: string) => mixed, 10 | }; 11 | 12 | type State = { 13 | inputNameValue: string, 14 | inputNamePristine: boolean, 15 | }; 16 | 17 | class DashboardTokensModal extends Component { 18 | state = { 19 | inputNameValue: '', 20 | inputNamePristine: true, 21 | }; 22 | 23 | _handleInputChange = (e: any) => { 24 | this.setState({ inputNameValue: e.target.value, inputNamePristine: false }); 25 | }; 26 | 27 | _handleConfirm = () => { 28 | this.props.onConfirm(this.state.inputNameValue); 29 | this.setState({ inputNameValue: '', inputNamePristine: true }); 30 | }; 31 | 32 | render() { 33 | const { inputNameValue, inputNamePristine } = this.state; 34 | const error = 35 | !inputNamePristine && !inputNameValue ? strings.API_TOKEN_GENERATOR_INPUT_REQUIRED : null; 36 | return ( 37 |
38 | 39 |
40 | 46 |
47 |
48 | 51 |
52 |
53 | ); 54 | } 55 | } 56 | 57 | export default DashboardTokensModal; 58 | -------------------------------------------------------------------------------- /client/components/HomeTerminal.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | :root { 4 | --console-width: 520px; 5 | } 6 | 7 | .HomeTerminal { 8 | width: var(--console-width); 9 | min-width: var(--console-width); 10 | height: 350px; 11 | border: 1px solid #333; 12 | border-radius: 5px; 13 | border-radius: 5px; 14 | background: #fff; 15 | display: flex; 16 | flex-direction: column; 17 | color: #000; 18 | transition: opacity 150ms ease-in; 19 | box-shadow: 70px 70px 100px 10px rgba(0, 0, 0, 0.1); 20 | } 21 | 22 | .HomeTerminal-header { 23 | width: var(--console-width); 24 | height: 36px; 25 | position: absolute; 26 | } 27 | 28 | .HomeTerminal-header-close { 29 | position: absolute; 30 | display: inline-block; 31 | top: 50%; 32 | left: 13px; 33 | width: 12px; 34 | height: 12px; 35 | border-radius: 50%; 36 | background-color: #ff5f56; 37 | } 38 | 39 | .HomeTerminal-header-minimize { 40 | position: absolute; 41 | display: inline-block; 42 | top: 50%; 43 | left: 33px; 44 | width: 12px; 45 | height: 12px; 46 | border-radius: 50%; 47 | background-color: #ffbd2e; 48 | } 49 | 50 | .HomeTerminal-header-full { 51 | position: absolute; 52 | display: inline-block; 53 | top: 50%; 54 | left: 53px; 55 | height: 12px; 56 | border-radius: 50%; 57 | width: 12px; 58 | background-color: #27c93f; 59 | } 60 | 61 | .HomeTerminal-header-title { 62 | position: absolute; 63 | width: var(--console-width); 64 | height: 36px; 65 | padding: 4px; 66 | top: 6px; 67 | min-height: 36px; 68 | display: flex; 69 | flex-direction: row; 70 | align-items: center; 71 | justify-content: center; 72 | color: #999; 73 | font-size: 13px; 74 | font-weight: 400; 75 | font-family: -apple-system,BlinkMacSystemFont,"Segoe UI", "Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans", "Droid Sans","Helvetica Neue",sans-serif; 76 | text-align: center; 77 | } 78 | 79 | .HomeTerminal-body { 80 | padding-top: 43px; 81 | font-size: 12px; 82 | font-family: Menlo,Monaco,Lucida Console,Liberation Mono, DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace,serif; 83 | line-height: 24px; 84 | margin: 0 16px; 85 | width: 100%; 86 | height: 100%; 87 | } 88 | 89 | .HomeTerminal-body span { 90 | width: 100%; 91 | height: 100%; 92 | white-space: pre; 93 | } 94 | 95 | .typed-cursor { 96 | opacity: 1; 97 | animation: typedjsBlink 0.7s infinite; 98 | -webkit-animation: typedjsBlink 0.7s infinite; 99 | animation: typedjsBlink 0.7s infinite; 100 | } 101 | 102 | @keyframes typedjsBlink { 103 | 50% { 104 | opacity: 0.0; 105 | } 106 | } 107 | 108 | @-webkit-keyframes typedjsBlink { 109 | 0% { 110 | opacity: 1; 111 | } 112 | 113 | 50% { 114 | opacity: 0.0; 115 | } 116 | 117 | 100% { 118 | opacity: 1; 119 | } 120 | } 121 | 122 | .typed-fade-out { 123 | opacity: 0; 124 | transition: opacity .25s; 125 | -webkit-animation: 0; 126 | animation: 0; 127 | } -------------------------------------------------------------------------------- /client/components/HomeTerminal.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import Typed from 'typed.js'; 4 | import style from './HomeTerminal.css'; 5 | 6 | type Props = {}; 7 | 8 | const CURL_COMMAND_1 = 9 | '` ~ $ `^500curl https://numvalidate.com/api/validate?number=12015550123'; 10 | 11 | const VALIDATION_OUTPUT_1 = `{ 12 | "valid": true, 13 | "number": "12015550123", 14 | "e164Format": "+12015550123", 15 | "internationalFormat": "+1 201-555-0123", 16 | "nationalFormat": "(201) 555-0123", 17 | "countryCode": "US", 18 | "countryPrefix": "1", 19 | "countryName": "United States" 20 | }`; 21 | 22 | const CURL_COMMAND_2 = 23 | '` ~ $ `^500curl https://numvalidate.com/api/validate?number=123456789'; 24 | 25 | const VALIDATION_OUTPUT_2 = `{ 26 | "valid": false, 27 | "number": "123456789" 28 | }`; 29 | 30 | const CURL_COMMAND_3 = 31 | '` ~ $ `^500curl https://numvalidate.com/api/validate?number=16518675309'; 32 | 33 | const VALIDATION_OUTPUT_3 = `{ 34 | "valid": true, 35 | "number": "16518675309", 36 | "e164Format": "+16518675309", 37 | "internationalFormat": "+1 651-867-5309", 38 | "nationalFormat": "(651) 867-5309" 39 | "countryCode": "US", 40 | "countryPrefix": "1", 41 | "countryName": "United States", 42 | }`; 43 | 44 | class HomeTerminal extends Component { 45 | componentDidMount() { 46 | new Typed('#typed-demo', { 47 | strings: [ 48 | '' + CURL_COMMAND_1 + '\n^2000 `' + VALIDATION_OUTPUT_1 + '\n ~ $`', 49 | '' + CURL_COMMAND_2 + '\n^2000 `' + VALIDATION_OUTPUT_2 + '\n ~ $`', 50 | '' + CURL_COMMAND_3 + '\n^2000 `' + VALIDATION_OUTPUT_3 + '\n ~ $`', 51 | ], 52 | smartBackspace: false, 53 | typeSpeed: 50, 54 | fadeOut: true, 55 | fadeOutDelay: 200, 56 | backDelay: 3000, 57 | loop: true, 58 | }); 59 | } 60 | 61 | render() { 62 | return ( 63 |
64 | 65 |
66 | 67 | 68 | 69 |
bash
70 |
71 |
72 | 73 |
74 |
75 | ); 76 | } 77 | } 78 | 79 | export default HomeTerminal; 80 | -------------------------------------------------------------------------------- /client/components/Modal.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .Modal-backdrop { 4 | position: fixed; 5 | top: 0; 6 | right: 0; 7 | bottom: 0; 8 | left: 0; 9 | background: rgba(0, 0, 0, 0.4); 10 | z-index: 99999; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | } 15 | 16 | .Modal { 17 | background-color: var(--color-background); 18 | border-radius: 4px; 19 | justify-content: space-between; 20 | animation: zoomIn 0.2s ease-out; 21 | } 22 | 23 | @media only screen and (max-width: 767px) { 24 | .Modal { 25 | width: 94%; 26 | } 27 | } 28 | 29 | .Modal-header { 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | border-bottom: 1px solid #e6ebf1; 34 | overflow: hidden; 35 | border-top-right-radius: 4px; 36 | border-top-left-radius: 4px; 37 | height: 60px; 38 | } 39 | 40 | .Modal-header > h2 { 41 | font-weight: 500; 42 | font-size: 17px; 43 | color: var(--color-text-1); 44 | margin-top: 0px; 45 | margin-bottom: 0px; 46 | } 47 | 48 | .Modal-header-back { 49 | display: flex; 50 | flex-direction: row; 51 | align-items: center; 52 | padding: 0px 20px; 53 | cursor: pointer; 54 | transition: all 0.2s ease-in; 55 | height: 60px; 56 | position: absolute; 57 | } 58 | 59 | .Modal-header-back:hover { 60 | opacity: 0.3; 61 | } 62 | 63 | .Modal-header-back-arrow { 64 | background-image: url("/static/ic_arrow_back_white_24px.svg"); 65 | height: 20px; 66 | width: 20px; 67 | background-position: 50% 50%; 68 | } 69 | 70 | .Modal-header-back-text { 71 | margin-left: 6px; 72 | margin-bottom: 0px; 73 | margin-top: 0px; 74 | color: inherit; 75 | font-weight: 400; 76 | font-size: 16px; 77 | } 78 | 79 | @media only screen and (max-width: 767px) { 80 | .Modal-header-back-text { 81 | display: none; 82 | } 83 | } 84 | 85 | .Modal-body { 86 | padding: 15px 20px; 87 | color: var(--color-text-2); 88 | } 89 | 90 | @keyframes zoomIn { 91 | from { 92 | opacity: 0; 93 | transform: scale3d(.3, .3, .3); 94 | } 95 | 96 | 50% { 97 | opacity: 1; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /client/components/Modal.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | 4 | import style from './Modal.css'; 5 | 6 | type Props = { 7 | title: string, 8 | onCancelClick: () => mixed, 9 | visible: boolean, 10 | children?: any, 11 | }; 12 | 13 | class Modal extends Component { 14 | render() { 15 | const { title, visible, onCancelClick, children } = this.props; 16 | if (visible) { 17 | return ( 18 |
19 | 20 |
21 |
22 |
23 |

{'Back'}

24 |
25 |
26 |

{title}

27 |
28 |
{children}
29 |
30 |
31 | ); 32 | } else { 33 | return null; 34 | } 35 | } 36 | } 37 | 38 | export default Modal; 39 | -------------------------------------------------------------------------------- /client/components/PageHead.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/components/PageHead.css -------------------------------------------------------------------------------- /client/components/PageHead.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import Head from 'next/head'; 4 | import keys from '../config/keys'; 5 | import style from './PageHead.css'; 6 | 7 | type Props = { 8 | title: string, 9 | }; 10 | 11 | const PageHead = (props: Props) => { 12 | return ( 13 | 14 | {props.title} 15 | 16 | 22 | {keys.GOOGLE_SITE_VERIFICATION && ( 23 | 24 | )} 25 | 26 | 27 | 28 | 29 | 35 | 39 | 43 | 44 | 50 | 51 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default PageHead; 65 | -------------------------------------------------------------------------------- /client/components/Snackbar.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .Snackbar { 4 | width: 100%; 5 | height: 52px; 6 | color: white; 7 | position: fixed; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | z-index: 99999; 12 | transition: all 0.5s; 13 | bottom: -52px; 14 | } 15 | 16 | .Snackbar-enter { 17 | bottom: 0px; 18 | opacity: 1; 19 | } 20 | 21 | .Snackbar-exit { 22 | bottom: -52px; 23 | opacity: 0; 24 | } 25 | 26 | .Snackbar-content { 27 | display: flex; 28 | height: 100%; 29 | min-width: 768px; 30 | max-width: 768px; 31 | padding: 16px; 32 | justify-content: space-between; 33 | align-items: center; 34 | background-color: #373c43; 35 | box-shadow: 3px 3px 10px 2px rgba(0, 0, 0, 0.5); 36 | } 37 | 38 | @media only screen and (max-width: 767px) { 39 | .Snackbar { 40 | height: 78px; 41 | } 42 | 43 | .Snackbar-content { 44 | min-width: 100%; 45 | width: 100%; 46 | max-width: 100%; 47 | word-wrap: break-word; 48 | bottom: -78px; 49 | } 50 | 51 | .Snackbar-exit { 52 | bottom: -78px; 53 | } 54 | 55 | .Snackbar-content p { 56 | font-size: 14px; 57 | word-wrap: break-word; 58 | } 59 | } 60 | 61 | @keyframes enter { 62 | from { 63 | bottom: 0; 64 | opacity: 0; 65 | } 66 | 67 | to { 68 | bottom: 86px; 69 | opacity: 1; 70 | } 71 | } 72 | 73 | @keyframes exit { 74 | from { 75 | bottom: 86px; 76 | opacity: 1; 77 | } 78 | 79 | to { 80 | bottom: 0; 81 | opacity: 0; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /client/components/Snackbar.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import Button from './Button'; 4 | import strings from '../config/strings'; 5 | import style from './Snackbar.css'; 6 | 7 | type Props = { 8 | message?: string, 9 | visible?: boolean, 10 | onCloseClick: () => mixed, 11 | }; 12 | 13 | class Snackbar extends Component { 14 | render() { 15 | const { message = '', visible, onCloseClick } = this.props; 16 | return ( 17 |
18 | 19 |
20 |

{message}

21 | 24 |
25 |
26 | ); 27 | } 28 | } 29 | 30 | export default Snackbar; 31 | -------------------------------------------------------------------------------- /client/components/Spinner.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | @keyframes spinner { 4 | to { 5 | transform: rotate(360deg); 6 | } 7 | } 8 | 9 | .Spinner-visible { 10 | box-sizing: border-box; 11 | top: 50%; 12 | left: 50%; 13 | width: 20px; 14 | height: 20px; 15 | margin-top: -10px; 16 | margin-left: -10px; 17 | border-radius: 50%; 18 | border-top: 2px solid var(--color-primary); 19 | border-right: 2px solid transparent; 20 | animation: spinner .6s linear infinite; 21 | } 22 | 23 | .Spinner-hidden { 24 | width: 20px; 25 | height: 20px; 26 | } -------------------------------------------------------------------------------- /client/components/Spinner.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import style from './Spinner.css'; 4 | 5 | type Props = { 6 | visible?: boolean, 7 | }; 8 | 9 | class Spinner extends Component { 10 | render() { 11 | const { visible, ...otherProps } = this.props; 12 | return ( 13 |
14 | 15 |
16 | ); 17 | } 18 | } 19 | 20 | export default Spinner; 21 | -------------------------------------------------------------------------------- /client/components/StripeCheckoutButton.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; -------------------------------------------------------------------------------- /client/components/StripeCheckoutButton.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from "react"; 3 | import StripeCheckout from "react-stripe-checkout"; 4 | import Button from "./Button"; 5 | import keys from "../config/keys"; 6 | import strings from "../config/strings"; 7 | import style from "./StripeCheckoutButton.css"; 8 | 9 | type Props = { 10 | text: string, 11 | amount: ?number, 12 | allowRememberMe?: boolean, 13 | email?: string, 14 | onStripeTokenReceived: (token: string) => mixed 15 | }; 16 | 17 | class StripeCheckoutButton extends Component { 18 | render() { 19 | const { 20 | amount, 21 | allowRememberMe, 22 | email, 23 | text, 24 | onStripeTokenReceived, 25 | ...otherProps 26 | } = this.props; 27 | return ( 28 | onStripeTokenReceived(token.id)} 30 | stripeKey={keys.STRIPE_PUBLIC_KEY} 31 | name={strings.APP_NAME} 32 | description={strings.APP_DESCRIPTION} 33 | amount={amount} 34 | allowRememberMe={allowRememberMe} 35 | panelLabel={text} 36 | email={email} 37 | image={"/static/logo@2x.png"} 38 | {...otherProps} 39 | > 40 | 41 | 42 | 43 | ); 44 | } 45 | } 46 | 47 | export default StripeCheckoutButton; 48 | -------------------------------------------------------------------------------- /client/components/TextInput.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .TextInput { 4 | } 5 | 6 | .TextInput-label { 7 | color: var(--color-primary); 8 | font-size: 16px; 9 | margin-bottom: 4px; 10 | } 11 | 12 | .TextInput-input { 13 | background-color: transparent; 14 | border: 0px; 15 | border-bottom: 1px solid var(--color-grey-light); 16 | font-size: 22px; 17 | color: var(--color-primary); 18 | caret-color: var(--color-primary); 19 | height: 38px; 20 | width: 100%; 21 | } 22 | 23 | @media only screen and (max-width: 767px) { 24 | .TextInput-input { 25 | font-size: 14px; 26 | } 27 | } 28 | 29 | .TextInput-input:focus { 30 | outline: none; 31 | } 32 | 33 | .TextInput-error { 34 | color: var(--color-error); 35 | font-size: 14px; 36 | margin-top: 4px; 37 | height: 20px; 38 | } 39 | -------------------------------------------------------------------------------- /client/components/TextInput.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import style from './TextInput.css'; 4 | 5 | type Props = { 6 | label?: ?string, 7 | error?: ?string, 8 | }; 9 | 10 | class TextInput extends Component { 11 | render() { 12 | const { label, error, ...otherProps } = this.props; 13 | return ( 14 |
15 | 16 | {label &&

{label}

} 17 | 18 |

{error}

19 |
20 | ); 21 | } 22 | } 23 | 24 | export default TextInput; 25 | -------------------------------------------------------------------------------- /client/components/Toolbar.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .Toolbar { 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | margin-bottom: 20px; 8 | } 9 | 10 | .Toolbar-left { 11 | display: flex; 12 | flex-direction: row; 13 | align-items: center; 14 | cursor: pointer; 15 | transition: all 0.4s; 16 | } 17 | 18 | .Toolbar-left:hover { 19 | opacity: 0.6; 20 | } 21 | 22 | .Toolbar-logo { 23 | min-width: 40px; 24 | height: 40px; 25 | filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.1)); 26 | border-radius: 10px; 27 | margin-right: 20px; 28 | background-image: url("/static/logo.png"); 29 | background-size: 40px 40px; 30 | background-repeat: no-repeat; 31 | } 32 | 33 | @media only screen and (max-width: 767px) { 34 | .Toolbar-logo { 35 | margin-right: 10px; 36 | } 37 | } 38 | 39 | @media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min--moz-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 2 / 1), only screen and (min-device-pixel-ratio: 2), only screen and (min-resolution: 192dpi), only screen and (min-resolution: 2dppx) { 40 | .Toolbar-logo { 41 | background-image: url("/static/logo@2x.png"); 42 | } 43 | } 44 | 45 | .Toolbar-title { 46 | margin: 0px; 47 | font-weight: 400; 48 | } 49 | 50 | .Toolbar-links { 51 | display: flex; 52 | flex-direction: row; 53 | width: 100%; 54 | justify-content: flex-end; 55 | align-items: center; 56 | } 57 | -------------------------------------------------------------------------------- /client/components/Toolbar.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import style from './Toolbar.css'; 4 | 5 | type Props = { 6 | onTitleClick?: () => mixed, 7 | children?: any, 8 | }; 9 | 10 | class TextInput extends Component { 11 | render() { 12 | const { onTitleClick } = this.props; 13 | return ( 14 |
15 | 16 |
null}> 17 |
18 |

NumValidate

19 |
20 |
{this.props.children}
21 |
22 | ); 23 | } 24 | } 25 | 26 | export default TextInput; 27 | -------------------------------------------------------------------------------- /client/config/keys.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import utils from '../utils'; 3 | 4 | export default { 5 | NODE_ENV: process.env.NODE_ENV || 'development', 6 | IS_ENV_DEVELOPMENT: process.env.NODE_ENV === 'development', 7 | IS_ENV_PRODUCTION: process.env.NODE_ENV === 'production', 8 | AUTH0_DOMAIN: process.env.REACT_APP_AUTH0_DOMAIN, 9 | AUTH0_CLIENT_ID: process.env.REACT_APP_AUTH0_CLIENT_ID, 10 | AUTH0_AUDIENCE: process.env.REACT_APP_AUTH0_AUDIENCE, 11 | DOCS_URL: process.env.REACT_APP_DOCS_URL || '', 12 | STRIPE_PUBLIC_KEY: process.env.REACT_APP_STRIPE_PUBLIC_KEY, 13 | STRIPE_FREE_PLAN_ID: process.env.REACT_APP_STRIPE_FREE_PLAN_ID, 14 | STRIPE_PRO_PLAN_ID: process.env.REACT_APP_STRIPE_PRO_PLAN_ID, 15 | STRIPE_PRO_PLAN_AMOUNT: utils.toInt(process.env.REACT_APP_STRIPE_PRO_PLAN_AMOUNT, 399), 16 | MAX_API_TOKENS_PER_USER: utils.toInt(process.env.REACT_APP_MAX_API_TOKENS_PER_USER, 5), 17 | RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS: utils.toInt( 18 | process.env.REACT_APP_RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS, 19 | 100 20 | ), 21 | RATE_LIMIT_FOR_FREE_USER_REQUESTS: utils.toInt( 22 | process.env.REACT_APP_RATE_LIMIT_FOR_FREE_USER_REQUESTS, 23 | 1000 24 | ), 25 | RATE_LIMIT_FOR_PRO_USER_REQUESTS: utils.toInt( 26 | process.env.REACT_APP_RATE_LIMIT_FOR_PRO_USER_REQUESTS, 27 | 10000 28 | ), 29 | GOOGLE_ANALYTICS_TRACKING_ID: process.env.REACT_APP_GOOGLE_ANALYTICS_TRACKING_ID, 30 | GOOGLE_SITE_VERIFICATION: process.env.REACT_APP_GOOGLE_SITE_VERIFICATION, 31 | }; 32 | -------------------------------------------------------------------------------- /client/config/strings.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import keys from './keys'; 3 | 4 | export default { 5 | APP_NAME: 'NumValidate', 6 | APP_DESCRIPTION: 'NumValidate description.', 7 | DOCS: 'Docs', 8 | CONFIRM: 'Confirm', 9 | DASHBOARD: 'Dashboard', 10 | CLOSE: 'Close', 11 | CANCEL: 'Cancel', 12 | LOGOUT: 'Logout', 13 | LOGIN: 'Login', 14 | SNACKBAR_TEXT: 'There has been an error processing your request, please try again later.', 15 | HOME_SIGNUP_NOW_AUTHENTICATED: 'Open your dashboard', 16 | HOME_SIGNUP_NOW_UNAUTHENTICATED: 'Signup now', 17 | DASHBOARD_ERROR_TITLE: 'Unauthorized', 18 | DASHBOARD_ERROR_MESSAGE_GENERIC: 'You are not authorized to access the dashboard.', 19 | DASHBOARD_ERROR_MESSAGE_VERIFY_EMAIL: 'Please verify your email before logging in.', 20 | DASHBOARD_ERROR_BUTTON: 'Login and try again', 21 | API_TOKEN_TITLE: 'API token', 22 | API_TOKEN_SUBTITLE: 'Your API token allows you to authenticate with NumValidate APIs.', 23 | API_TOKEN_REVEAL_BUTTON: 'Reveal token', 24 | API_TOKEN_GENERATE_BUTTON: 'Generate a new API token', 25 | API_TOKEN_TABLE_HEADER_NAME: 'Name', 26 | API_TOKEN_TABLE_HEADER_TOKEN: 'Token', 27 | API_TOKEN_TABLE_HEADER_UPDATED: 'Last update', 28 | API_TOKEN_GENERATOR_INPUT_REQUIRED: 'Required', 29 | API_TOKEN_GENERATOR_INPUT_PLACEHOLDER: 'Assign a meaningful name to the token', 30 | API_TOKEN_GENERATOR_BUTTON_TEXT: 'Confirm', 31 | API_TOKEN_GENERATOR_MODAL_TITLE: 'New API token', 32 | SUBSCRIPTION_TITLE: 'Your subscription', 33 | SUBSCRIPTION_SUBTITLE: 'You can change your subscription type anytime.', 34 | SUBSCRIPTION_TABLE_HEADER_SUBSCRIPTION: 'Subscription', 35 | SUBSCRIPTION_SELECT_BUTTON: 'Select', 36 | SUBSCRIPTION_CURRENT_PLAN: 'Your current plan', 37 | PAYMENT_INFO_TITLE: 'Your payment info', 38 | PAYMENT_INFO_SUBTITLE: 'Your card details for the monthly NumValidate billing.', 39 | PAYMENT_INFO_UPDATE_BUTTON: 'Update payment info', 40 | PAYMENT_INFO_TABLE_HEADER_CARD: 'Card details', 41 | PAYMENT_INFO_TABLE_HEADER_UPDATED: 'Last update', 42 | PAYMENT_INFO_FREE_PLAN_AMOUNT: `€0/month`, 43 | PAYMENT_INFO_FREE_PLAN_DESCRIPTION: `${keys.RATE_LIMIT_FOR_FREE_USER_REQUESTS || 44 | ''} daily API requests.`, 45 | PAYMENT_INFO_PRO_PLAN_AMOUNT: `€${keys.STRIPE_PRO_PLAN_AMOUNT / 100 || ''}/month`, 46 | PAYMENT_INFO_PRO_PLAN_DESCRIPTION: `${keys.RATE_LIMIT_FOR_PRO_USER_REQUESTS || 47 | ''} daily API requests. Email Support.`, 48 | PAYMENT_INFO_FREE_MODAL_TITLE: 'Subscription change', 49 | PAYMENT_INFO_FREE_MODAL_TEXT: 'Do you confirm the change to the free plan?', 50 | PAYMENT_INFO_PRO_MODAL_TITLE: 'Subscription change', 51 | PAYMENT_INFO_PRO_MODAL_TEXT: 52 | "Do you confirm the change to the pro plan?\nYou'll be asked to enter your credit card detail.", 53 | TEST: 54 | 'NumValidates is powered by Google LibPhoneNumber, a phon number formatting and parsing library released by Google, originally developed for (and currently used in) Google\'s Androi mobile phone operating system, which uses a several rigorous rules for parsing, formatting, and validating phone numbers for all countries/regions of the world.', 55 | }; 56 | -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /client/favicons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /client/favicons/code.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /client/favicons/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/favicon-128.png -------------------------------------------------------------------------------- /client/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /client/favicons/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/favicon-196x196.png -------------------------------------------------------------------------------- /client/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /client/favicons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/favicon-96x96.png -------------------------------------------------------------------------------- /client/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/favicon.ico -------------------------------------------------------------------------------- /client/favicons/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/mstile-144x144.png -------------------------------------------------------------------------------- /client/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /client/favicons/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/mstile-310x150.png -------------------------------------------------------------------------------- /client/favicons/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/mstile-310x310.png -------------------------------------------------------------------------------- /client/favicons/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/favicons/mstile-70x70.png -------------------------------------------------------------------------------- /client/globals.css: -------------------------------------------------------------------------------- 1 | @import './colors'; 2 | 3 | *, 4 | :after, 5 | :before { 6 | box-sizing: border-box; 7 | } 8 | 9 | html { 10 | width: 100%; 11 | height: 100%; 12 | min-height: 100%; 13 | font-family: sans-serif; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | body { 18 | overflow-x: hidden; 19 | width: 100%; 20 | min-width: 300px; 21 | height: 100%; 22 | min-height: 100%; 23 | margin: 0; 24 | padding: 0; 25 | color: var(--color-primary); 26 | background: var(--color-background); 27 | font-family: -apple-system,BlinkMacSystemFont,"Segoe UI", "Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans", "Droid Sans","Helvetica Neue",sans-serif; 28 | font-size: 100%; 29 | font-weight: 400; 30 | -webkit-font-smoothing: antialiased; 31 | } 32 | 33 | p { 34 | margin-bottom: 10px; 35 | font-size: 16px; 36 | line-height: 1.6; 37 | } 38 | 39 | h1 { 40 | font-size: 35px; 41 | font-weight: 200; 42 | margin-bottom: 20px; 43 | } 44 | 45 | h2 { 46 | font-size: 30px; 47 | font-weight: 200; 48 | margin-bottom: 20px; 49 | } 50 | 51 | a { 52 | outline: 0; 53 | } 54 | 55 | a, 56 | a:visited { 57 | transition: opacity 0.5s ease-out; 58 | text-decoration: none; 59 | opacity: 0.7; 60 | color: var(--color-primary); 61 | text-decoration: none; 62 | color: var(--color-primary); 63 | cursor: pointer; 64 | } 65 | 66 | a:hover, 67 | a:focus, 68 | a:visited:hover, 69 | a:visited:focus { 70 | opacity: 1; 71 | } 72 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack: (config, { dev }) => { 3 | config.module.rules.push( 4 | { 5 | test: /\.css$/, 6 | loader: 'emit-file-loader', 7 | options: { 8 | name: 'dist/[path][name].[ext]', 9 | }, 10 | }, 11 | { 12 | test: /\.css$/, 13 | use: ['babel-loader', 'raw-loader', 'postcss-loader'], 14 | } 15 | ); 16 | return config; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /client/pages/Dashboard.css: -------------------------------------------------------------------------------- 1 | @import '../colors'; 2 | 3 | .Dashboard { 4 | display: block; 5 | overflow: hidden; 6 | box-sizing: border-box; 7 | width: 100%; 8 | max-width: 900px; 9 | margin: 0 auto; 10 | padding: 15px; 11 | } 12 | 13 | .Dashboard-error { 14 | display: flex; 15 | width: 100%; 16 | height: 100vh; 17 | flex-direction: column; 18 | align-items: center; 19 | justify-content: center; 20 | text-align: center; 21 | } 22 | 23 | .Dashboard-error p { 24 | margin-bottom: 40px; 25 | } 26 | 27 | .Dashboard-loading { 28 | display: flex; 29 | width: 100%; 30 | height: 100vh; 31 | flex-direction: column; 32 | align-items: center; 33 | justify-content: center; 34 | } 35 | 36 | .Dashboard-content { 37 | display: block; 38 | width: 100%; 39 | margin-top: 40px; 40 | } 41 | 42 | .Dashboard-toolbar a { 43 | font-size: 14px; 44 | margin-left: 20px; 45 | text-transform: uppercase; 46 | } 47 | -------------------------------------------------------------------------------- /client/pages/Dashboard.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import _ from 'lodash'; 4 | import PageHead from '../components/PageHead'; 5 | import Button from '../components/Button'; 6 | import Spinner from '../components/Spinner'; 7 | import Snackbar from '../components/Snackbar'; 8 | import Toolbar from '../components/Toolbar'; 9 | import backendService from '../services/backend'; 10 | import analyticsService from '../services/analytics'; 11 | import authService from '../services/auth'; 12 | import DashboardSubscription from '../components/DashboardSubscription'; 13 | import DashboardCreditCard from '../components/DashboardCreditCard'; 14 | import DashboardTokens from '../components/DashboardTokens'; 15 | import keys from '../config/keys'; 16 | import strings from '../config/strings'; 17 | import utils from '../utils'; 18 | import globalStyle from '../globals.css'; 19 | import style from './Dashboard.css'; 20 | 21 | import type { User } from '../types'; 22 | 23 | const SNACKBAR_AUTO_HIDE_DELAY_IN_MS = 4000; 24 | 25 | type Props = { 26 | cookieAccessToken?: string, 27 | }; 28 | 29 | type State = { 30 | initialized: boolean, 31 | initializationError?: string, 32 | emailVerified: boolean, 33 | authenticated: boolean, 34 | snackbarVisible: boolean, 35 | snackbarMessage: string, 36 | user?: User, 37 | }; 38 | 39 | class Dashboard extends Component { 40 | static getInitialProps(ctx) { 41 | const cookieAccessToken = authService.getAccessToken(ctx.req); 42 | return { cookieAccessToken }; 43 | } 44 | 45 | state = { 46 | initialized: false, 47 | initializationError: undefined, 48 | emailVerified: false, 49 | authenticated: false, 50 | snackbarVisible: false, 51 | snackbarMessage: '', 52 | user: undefined, 53 | }; 54 | 55 | componentDidMount() { 56 | analyticsService.initialize(); 57 | analyticsService.pageView(); 58 | this._initialize(); 59 | } 60 | 61 | _initialize = async () => { 62 | authService.initialize(); 63 | let error; 64 | // First, ensure that a token exists by checking the page url hash (if the 65 | // user has just completed a succesfull login you'll find here the token). 66 | // If the page url doesn't come from a succesfull login redirect, just use 67 | // the token saved in the cookie. 68 | let accessToken; 69 | try { 70 | accessToken = (await authService.parseHash()) || this.props.cookieAccessToken; 71 | } catch (err) { 72 | error = err; 73 | } 74 | if (!accessToken || error) { 75 | const auth0ErrorMessage = error && error.errorDescription; 76 | this.setState({ 77 | initialized: true, 78 | authenticated: false, 79 | emailVerified: auth0ErrorMessage !== 'Please verify your email before logging in.', 80 | initializationError: auth0ErrorMessage || strings.DASHBOARD_ERROR_MESSAGE_GENERIC, 81 | }); 82 | return; 83 | } 84 | // Then, initialize the backend service with the token and fetch for the 85 | // user infos. 86 | // Also, create a Stripe Customer for the user if needed. 87 | backendService.initialize(accessToken); 88 | let user; 89 | try { 90 | user = await backendService.getUser(); 91 | if (!user.initialized) { 92 | user = await backendService.createUserCustomer(); 93 | } 94 | } catch (err) { 95 | error = err; 96 | } 97 | if (!user || error) { 98 | this.setState({ 99 | initialized: true, 100 | authenticated: false, 101 | initializationError: strings.DASHBOARD_ERROR_MESSAGE_GENERIC, 102 | }); 103 | return; 104 | } 105 | // If everything was ok, save the user in the state 106 | this.setState({ initialized: true, authenticated: true, user: user }); 107 | }; 108 | 109 | _handleLogout = () => { 110 | authService.logout(); 111 | window.location.replace(`${window.location.origin}`); 112 | }; 113 | 114 | _handleToolbarTitleClick = () => { 115 | window.location.href = `${window.location.origin}`; 116 | }; 117 | 118 | _handleErrorButtonClick = () => { 119 | authService.logout(); 120 | window.location.replace(`${window.location.origin}`); 121 | }; 122 | 123 | _showSnackbar = async (error: Error) => { 124 | console.log('error', error); 125 | this.setState({ 126 | snackbarVisible: true, 127 | snackbarMessage: error.message || strings.SNACKBAR_TEXT, 128 | }); 129 | await utils.delay(SNACKBAR_AUTO_HIDE_DELAY_IN_MS); 130 | this._hideSnackbar(); 131 | }; 132 | 133 | _hideSnackbar = () => { 134 | this.setState({ snackbarVisible: false }); 135 | }; 136 | 137 | _updateUser = (user: User) => { 138 | this.setState({ user: user }); 139 | }; 140 | 141 | render() { 142 | const { authenticated, snackbarVisible, snackbarMessage, initialized, user = {} } = this.state; 143 | 144 | if (!initialized) { 145 | return ( 146 |
147 | 148 | 151 | 152 |
153 | 154 |
155 |
156 | ); 157 | } else if (!authenticated) { 158 | return ( 159 |
160 | 161 | 164 | 165 |
166 |

{strings.DASHBOARD_ERROR_TITLE}

167 |

{this.state.initializationError}

168 | 171 |
172 |
173 | ); 174 | } else { 175 | return ( 176 |
177 |
178 | 179 | 182 | 183 |
184 | 185 | {strings.DOCS} 186 | {strings.LOGOUT} 187 | 188 |
189 |
190 | 197 | 202 | {!_.isEmpty(user.creditCard) && 203 | user.creditCard && 204 | user.subscription.planId !== keys.STRIPE_FREE_PLAN_ID && ( 205 | 211 | )} 212 |
213 |
214 | 219 |
220 | ); 221 | } 222 | } 223 | } 224 | 225 | export default Dashboard; 226 | -------------------------------------------------------------------------------- /client/pages/Home.css: -------------------------------------------------------------------------------- 1 | @import '../colors.css'; 2 | 3 | .Home { 4 | display: block; 5 | overflow: hidden; 6 | box-sizing: border-box; 7 | width: 100%; 8 | max-width: 900px; 9 | margin: 0 auto; 10 | padding: 15px; 11 | } 12 | 13 | /*========================== 14 | SEPARATOR 15 | ==========================*/ 16 | .Home-sep { 17 | display: block; 18 | clear: both; 19 | width: 100%; 20 | height: 2px; 21 | margin: 15px 0; 22 | background: var(--color-grey-dark); 23 | } 24 | 25 | @media only screen and (min-width: 767px) { 26 | .sep { 27 | margin-top: 0; 28 | } 29 | } 30 | 31 | /*========================== 32 | INTRO 33 | ==========================*/ 34 | .Home-intro { 35 | display: flex; 36 | flex-direction: column; 37 | height: 100vh; 38 | } 39 | 40 | @media only screen and (max-width: 1280px) { 41 | .Home-intro { 42 | max-height: 1080px; 43 | } 44 | } 45 | 46 | .Home-intro-toolbar a { 47 | font-size: 14px; 48 | margin-left: 20px; 49 | text-transform: uppercase; 50 | } 51 | 52 | .Home-intro-content { 53 | display: flex; 54 | height: 100%; 55 | flex-direction: column; 56 | align-items: center; 57 | justify-content: center; 58 | flex: 1; 59 | } 60 | 61 | .Home-intro-title { 62 | text-align: center; 63 | max-width: 600px; 64 | margin-bottom: 50px; 65 | } 66 | 67 | @media only screen and (max-width: 767px) { 68 | .Home-intro-title { 69 | margin-bottom: 20px; 70 | } 71 | } 72 | 73 | .Home-intro-title-bold { 74 | font-weight: 400; 75 | } 76 | 77 | .Home-intro-description { 78 | display: flex; 79 | flex-direction: column; 80 | text-align: center; 81 | max-width: 560px; 82 | margin-bottom: 60px; 83 | } 84 | 85 | .Home-intro-description p { 86 | font-size: 18px; 87 | } 88 | 89 | @media only screen and (max-width: 767px) { 90 | .Home-intro-description { 91 | margin-bottom: 40px; 92 | } 93 | } 94 | 95 | .Home-intro-description h1 { 96 | margin-bottom: 20px; 97 | } 98 | 99 | .Home-intro-description a { 100 | text-decoration: underline; 101 | } 102 | 103 | .Home-intro-terminal { 104 | margin-bottom: 20px; 105 | } 106 | 107 | .Home-intro-button { 108 | margin-bottom: 20px; 109 | } 110 | 111 | @media only screen and (max-width: 767px) { 112 | .Home-intro-terminal { 113 | display: none; 114 | } 115 | } 116 | 117 | /*========================== 118 | PLANS 119 | ==========================*/ 120 | .Home-plans { 121 | display: flex; 122 | flex-direction: column; 123 | margin-bottom: 60px; 124 | margin-top: 60px; 125 | } 126 | 127 | .Home-plans-row { 128 | display: flex; 129 | flex-direction: row; 130 | justify-content: space-around; 131 | margin-bottom: 60px; 132 | margin-top: 40px; 133 | } 134 | 135 | .Home-plans a { 136 | text-decoration: underline; 137 | } 138 | 139 | @media only screen and (max-width: 767px) { 140 | .Home-plans-row { 141 | justify-content: initial; 142 | flex-direction: column; 143 | align-items: center; 144 | } 145 | } 146 | 147 | .Home-plans-plan { 148 | max-width: 32%; 149 | min-height: 100%; 150 | border: 1px solid var(--color-grey-dark); 151 | width: 100%; 152 | text-align: center; 153 | padding-bottom: 40px; 154 | margin: 10px 0; 155 | } 156 | 157 | @media only screen and (max-width: 767px) { 158 | .Home-plans-plan { 159 | min-height: unset; 160 | max-width: 420px; 161 | padding-bottom: 10px; 162 | margin-bottom: 20px; 163 | } 164 | } 165 | 166 | .Home-plans-plan-name { 167 | font-size: 27px; 168 | font-weight: 600; 169 | padding-top: 40px; 170 | padding-bottom: 40px; 171 | margin-bottom: 10px; 172 | } 173 | 174 | @media only screen and (max-width: 767px) { 175 | .Home-plans-plan-name { 176 | font-size: 25px; 177 | padding-top: 10px; 178 | padding-bottom: 10px; 179 | } 180 | } 181 | 182 | .Home-plans-plan-price { 183 | font-size: 42px; 184 | margin: 0px; 185 | padding: 30px; 186 | } 187 | 188 | @media only screen and (max-width: 767px) { 189 | .Home-plans-plan-price { 190 | padding: 10px; 191 | } 192 | } 193 | 194 | .Home-plans-plan-price:before { 195 | content: '\20AC'; 196 | color: #666; 197 | font-size: 24px; 198 | line-height: 2; 199 | vertical-align: top; 200 | margin-right: 6px; 201 | } 202 | 203 | .Home-plans-plan-price:after { 204 | content: '/mo'; 205 | color: #666; 206 | font-size: 30px; 207 | margin-left: 6px; 208 | } 209 | 210 | .Home-plans-plan ul { 211 | text-align: left; 212 | list-style: none; 213 | padding-left: 40px; 214 | padding-right: 10px; 215 | } 216 | 217 | .Home-plans-plan ul li { 218 | font-size: 16px; 219 | margin-bottom: 12px; 220 | } 221 | 222 | .Home-plans-plan ul li:before { 223 | content: '\2022'; 224 | display: block; 225 | position: relative; 226 | max-width: 0; 227 | max-height: 0; 228 | left: -15px; 229 | color: #fff; 230 | line-height: normal; 231 | font-size: 18px; 232 | } 233 | 234 | .Home-plans-plan-footer { 235 | width: 100%; 236 | text-align: center; 237 | } 238 | 239 | /*========================== 240 | FAQ 241 | ==========================*/ 242 | .Home-faq { 243 | margin-bottom: 60px; 244 | margin-top: 80px; 245 | } 246 | 247 | .Home-faq h1 { 248 | margin-bottom: 40px; 249 | } 250 | 251 | .Home-faq-question a { 252 | text-decoration: underline; 253 | } 254 | 255 | .Home-faq-question h3 { 256 | margin-bottom: 6px; 257 | } 258 | 259 | .Home-faq-question p { 260 | margin-bottom: 40px; 261 | } 262 | 263 | /*========================== 264 | FOOTER 265 | ==========================*/ 266 | .Home-footer { 267 | margin-bottom: 30px; 268 | margin-top: 30px; 269 | display: flex; 270 | flex-direction: row; 271 | justify-content: space-between; 272 | align-items: center; 273 | } 274 | 275 | .Home-footer-left { 276 | display: flex; 277 | flex-direction: row; 278 | align-items: center; 279 | } 280 | 281 | .Home-footer-left > a { 282 | margin-left: 12px; 283 | } 284 | 285 | @media only screen and (max-width: 767px) { 286 | .Home-footer { 287 | flex-direction: column; 288 | align-items: center; 289 | } 290 | 291 | .Home-footer-left { 292 | display: flex; 293 | flex-direction: row; 294 | align-items: center; 295 | } 296 | 297 | .Home-footer-left > a { 298 | margin-left: initial; 299 | margin: 16px 30px; 300 | } 301 | } 302 | 303 | .Home-footer p { 304 | opacity: 0.7; 305 | } 306 | 307 | .Home-footer-credit a { 308 | opacity: 1; 309 | } 310 | 311 | .Home-footer-credit a:hover { 312 | text-decoration: underline; 313 | } 314 | 315 | .Home-footer-contact a { 316 | padding-right: 0; 317 | } 318 | -------------------------------------------------------------------------------- /client/pages/Home.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import PageHead from '../components/PageHead'; 4 | import Toolbar from '../components/Toolbar'; 5 | import Button from '../components/Button'; 6 | import HomeTerminal from '../components/HomeTerminal'; 7 | import authService from '../services/auth'; 8 | import analyticsService from '../services/analytics'; 9 | import keys from '../config/keys'; 10 | import strings from '../config/strings'; 11 | import globalStyle from '../globals.css'; 12 | import style from './Home.css'; 13 | 14 | type Props = { 15 | authenticated: boolean, 16 | }; 17 | 18 | class Home extends Component { 19 | static getInitialProps(ctx) { 20 | const authenticated = authService.isAuthenticated(ctx.req); 21 | return { authenticated }; 22 | } 23 | 24 | componentDidMount() { 25 | authService.initialize(); 26 | analyticsService.initialize(); 27 | analyticsService.pageView(); 28 | } 29 | 30 | _handleToolbarTitleClick = () => { 31 | window.location.href = `${window.location.origin}`; 32 | }; 33 | 34 | _handleDashboardClick = () => { 35 | window.location.href = `${window.location.origin}/dashboard`; 36 | }; 37 | 38 | _handleLoginClick = () => { 39 | authService.authorize(); 40 | }; 41 | 42 | render() { 43 | return ( 44 |
45 | 46 | 49 | 50 | 51 |
52 |
53 | 54 | {strings.DOCS} 55 | {this.props.authenticated ? ( 56 | {strings.DASHBOARD} 57 | ) : ( 58 | {strings.LOGIN} 59 | )} 60 | 61 |
62 | 63 |
64 |
65 |

66 | Phone number validation REST API. 67 |
68 | Simple,{' '} 69 | Free, and{' '} 70 | Open Source. 71 |

72 |
73 |
74 | 75 |
76 |
77 |

78 | NumValidate is an open source REST API that provides a simple yet effective way to 79 | validate and format a phone number. 80 |

81 |
82 |
83 | {this.props.authenticated ? ( 84 | 87 | ) : ( 88 | 91 | )} 92 |
93 |
94 |
95 | 96 |
97 | 98 |
99 |

Available plans

100 |
101 |
102 |

Unauthenticated

103 |

0

104 |
    105 |
  • Fully-featured validation
  • 106 |
  • 107 | Up to {keys.RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS} daily API requests 108 |
  • 109 |
110 |
111 |
112 |

Free

113 |

0

114 |
    115 |
  • Fully-featured validation
  • 116 |
  • 117 | Up to {keys.RATE_LIMIT_FOR_FREE_USER_REQUESTS} daily API requests 118 |
  • 119 |
  • API tokens generation
  • 120 |
121 |
122 |
123 |

Pro

124 |

3.99

125 |
    126 |
  • Fully-featured validation
  • 127 |
  • 128 | Up to {keys.RATE_LIMIT_FOR_PRO_USER_REQUESTS} daily API requests 129 |
  • 130 |
  • API tokens generation
  • 131 |
  • Private email support
  • 132 |
133 |
134 |
135 |

136 | If your project is open source or you just need an higher rate limit feel free to{' '} 137 | 141 | contact me 142 | 143 | . 144 |

145 |
146 | 147 |
148 | 149 |
150 |

FAQ

151 |
152 |

How are phone numbers validated?

153 |

154 | NumValidates is powered by{' '} 155 | 156 | Google LibPhoneNumber 157 | , a phone number formatting and parsing library released by Google, originally 158 | developed for (and currently used in) Google's Android mobile phone operating system, 159 | which uses several rigorous rules for parsing, formatting, and validating phone 160 | numbers for all countries/regions of the world. 161 |

162 |
163 |
164 |

What should I use NumValidate for?

165 |

166 | Validating a phone number allows you to keep your user database clean and fight frauds 167 | by validating phone numbers at the point of entry into your system.
NumValidate 168 | also allows you to format a phone number in the{' '} 169 | 170 | E164 format 171 | , which is the standard that you should use for safely storing you phone numbers. 172 |

173 |
174 |
175 |

What's the reason behind the rate limiting?

176 |

177 | Simply put: the rate limit ensures an high quality of service for all API consumers.
{' '} 178 | To enjoy the default rate limit of {keys.RATE_LIMIT_FOR_FREE_USER_REQUESTS} requests 179 | per day, you'll need to sign-up for a free account and then head to your dashboard to 180 | generate an API keys. 181 |

182 |
183 |
184 |

Why is there a paid plan? Isn't this a free project?

185 |

186 | The paid plan{' '} 187 | 188 | should 189 | {' '} 190 | be the main way to pay up the infrastructure.
However, this is an open source 191 | project: You can find all its code on{' '} 192 | 193 | Github 194 | , and if you think that the proposed price for the pro plan is too high you're 195 | free to install it in your own server.
196 | Payments can be made via Credit Card (Visa, MasterCard, Discover, Diner's Club) and 197 | are secured by{' '} 198 | 199 | Stripe 200 | .
You can change your payment method at any given time in the Payment section 201 | of your Account Dashboard. 202 |

203 |
204 |
205 |

Could you tell us the story behind NumValidate?

206 |

207 | Of course!
208 | NumValidate was born in the summer 2017 from a meeting between the holiday boredom and 209 | the desire to test myself in building a "simple API gateway".
You can see by 210 | yourself in the Github repository that only a small portion of the code handles the 211 | phone number validation, while the biggest part of the project just supports the 212 | authentication, caching and API token management. 213 |

214 |
215 |
216 |

Is there any way I can support the project?

217 |

218 | I'm putting this in the FAQ even if we all know this is not a frequent question by any 219 | means シ.
If you're liking NumValidate, the best way to support the project is by 220 | contributing to it on Github: Pull requests with new features/fixes and discussions 221 | are warmly welcomed.
222 |

223 |
224 |
225 | 226 |
227 | 228 |
229 |

230 | Made with ♥ by{' '} 231 | 232 | Mazzarolo Matteo 233 | 234 |

235 | 243 |
244 |
245 | ); 246 | } 247 | } 248 | 249 | export default Home; 250 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-easy-import')({ prefix: '_' }), 4 | require('postcss-custom-properties')(), 5 | require('autoprefixer')(), 6 | require('cssnano')(), 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /client/services/analytics.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import ReactGA from 'react-ga'; 3 | import keys from '../config/keys'; 4 | 5 | type EventParams = { 6 | category?: string, 7 | action?: string, 8 | label?: string, 9 | value?: number, 10 | nonInteraction?: boolean, 11 | transport?: string, 12 | }; 13 | 14 | // $FlowFixMe 15 | const isAnalyticsEnabled = keys.GOOGLE_ANALYTICS_TRACKING_ID && process.browser; 16 | 17 | const initialize = () => { 18 | if (!isAnalyticsEnabled) return; 19 | ReactGA.initialize(keys.GOOGLE_ANALYTICS_TRACKING_ID); 20 | }; 21 | 22 | const pageView = () => { 23 | if (!isAnalyticsEnabled) return; 24 | const page = `${window.location.pathname}${window.location.search}`; 25 | ReactGA.set({ page: page }); 26 | ReactGA.pageview(page); 27 | }; 28 | 29 | const event = (eventParams: EventParams) => { 30 | if (!isAnalyticsEnabled) return; 31 | ReactGA.event(eventParams); 32 | }; 33 | 34 | export default { 35 | initialize, 36 | pageView, 37 | event, 38 | }; 39 | -------------------------------------------------------------------------------- /client/services/auth.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import Auth0 from 'auth0-js'; 3 | import Cookie from 'js-cookie'; 4 | import keys from '../config/keys'; 5 | 6 | const ACCESS_TOKEN_COOKIE_NAME = 'numvalidateAccessToken'; 7 | 8 | let auth0WebAuth; 9 | 10 | const initialize = () => { 11 | auth0WebAuth = new Auth0.WebAuth({ 12 | domain: keys.AUTH0_DOMAIN, 13 | clientID: keys.AUTH0_CLIENT_ID, 14 | redirectUri: `${window.location.origin}/dashboard`, 15 | audience: keys.AUTH0_AUDIENCE, 16 | responseType: 'token id_token', 17 | scope: 'openid', 18 | }); 19 | }; 20 | 21 | const setAuthCookie = (accessToken: string, tokenExpiresIn: number) => { 22 | if (!process.browser) { 23 | return; 24 | } 25 | const cookieExpiry = new Date(new Date().getTime() + tokenExpiresIn * 1000); 26 | Cookie.set(ACCESS_TOKEN_COOKIE_NAME, accessToken, { expires: cookieExpiry }); 27 | }; 28 | 29 | const removeAuthCookie = () => { 30 | if (!process.browser) { 31 | return; 32 | } 33 | Cookie.remove(ACCESS_TOKEN_COOKIE_NAME); 34 | }; 35 | 36 | const authorize = () => { 37 | auth0WebAuth.authorize(); 38 | }; 39 | 40 | const logout = () => { 41 | if (!process.browser) { 42 | return; 43 | } 44 | removeAuthCookie(); 45 | window.localStorage.setItem('loggedOutAt', Date.now()); 46 | }; 47 | 48 | const getAccessToken = (req?: any): ?string => { 49 | if (process.browser) { 50 | return Cookie.getJSON(ACCESS_TOKEN_COOKIE_NAME); 51 | } else { 52 | if (!req || !req.headers || !req.headers.cookie) { 53 | return undefined; 54 | } 55 | const accessTokenCookie = req.headers.cookie 56 | .split(';') 57 | .find(cookie => cookie.trim().startsWith(`${ACCESS_TOKEN_COOKIE_NAME}=`)); 58 | if (!accessTokenCookie) { 59 | return undefined; 60 | } 61 | return accessTokenCookie.split('=')[1]; 62 | } 63 | }; 64 | 65 | const isAuthenticated = (req?: any): boolean => { 66 | return getAccessToken(req) !== undefined; 67 | }; 68 | 69 | const parseHash = async (): Promise => { 70 | return new Promise((resolve, reject) => { 71 | auth0WebAuth.parseHash(window.location.hash, (err, authResult) => { 72 | if (authResult && authResult.accessToken && authResult.expiresIn) { 73 | setAuthCookie(authResult.accessToken, authResult.expiresIn); 74 | return resolve(authResult.accessToken); 75 | } else if (err) { 76 | return reject(err); 77 | } else { 78 | if (isAuthenticated()) { 79 | return resolve(); 80 | } else { 81 | return reject(`authResult: ${authResult}`); 82 | } 83 | } 84 | }); 85 | }); 86 | }; 87 | 88 | export default { 89 | authorize, 90 | parseHash, 91 | logout, 92 | getAccessToken, 93 | isAuthenticated, 94 | initialize, 95 | }; 96 | -------------------------------------------------------------------------------- /client/services/backend.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | let accessToken = null; 3 | 4 | const initialize = (token: string) => { 5 | accessToken = token; 6 | }; 7 | 8 | const callBackendEndpoint = async (method: any, endpoint: string, body: ?Object) => { 9 | if (!accessToken) { 10 | throw new Error('No access token found'); 11 | } 12 | const headers = { 13 | Authorization: `Bearer ${accessToken}`, 14 | 'Content-Type': 'application/json', 15 | }; 16 | const url = `${window.location.origin}${endpoint}`; 17 | const stringifiedBody = 18 | method === 'GET' || method === 'HEAD' ? undefined : JSON.stringify(body || {}); 19 | const response = await fetch(url, { 20 | method: method, 21 | headers: headers, 22 | body: stringifiedBody, 23 | }); 24 | const responseBody = await response.json(); 25 | if (!response.ok || responseBody.error) { 26 | const error = responseBody.error || {}; 27 | throw new Error(error.message || response.statusText || 'Connection error'); 28 | } 29 | return responseBody; 30 | }; 31 | 32 | const getUser = async () => { 33 | const res = await callBackendEndpoint('GET', '/user'); 34 | return res.data; 35 | }; 36 | 37 | const createUserCustomer = async () => { 38 | const res = await callBackendEndpoint('POST', '/user/customer'); 39 | return res.data; 40 | }; 41 | 42 | const createUserApiToken = async (tokenName: string) => { 43 | const res = await callBackendEndpoint('POST', '/user/token', { tokenName }); 44 | return res.data; 45 | }; 46 | 47 | const deleteUserApiToken = async (tokenValue: string) => { 48 | const res = await callBackendEndpoint('DELETE', `/user/token/${tokenValue}`); 49 | return res.data; 50 | }; 51 | 52 | const updateUserSubscription = async (planId: string, stripeSource?: string) => { 53 | const res = await callBackendEndpoint('PATCH', '/user/subscription', { 54 | planId: planId, 55 | stripeSource, 56 | }); 57 | return res.data; 58 | }; 59 | 60 | const updateUserPaymentInfo = async (stripeToken: string) => { 61 | const res = await callBackendEndpoint('PATCH', '/user/source', { 62 | stripeSource: stripeToken, 63 | }); 64 | return res.data; 65 | }; 66 | 67 | export default { 68 | initialize, 69 | getUser, 70 | createUserApiToken, 71 | deleteUserApiToken, 72 | updateUserSubscription, 73 | updateUserPaymentInfo, 74 | createUserCustomer, 75 | }; 76 | -------------------------------------------------------------------------------- /client/static/favicons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /client/static/favicons/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/favicon-128.png -------------------------------------------------------------------------------- /client/static/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /client/static/favicons/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/favicon-196x196.png -------------------------------------------------------------------------------- /client/static/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /client/static/favicons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/favicon-96x96.png -------------------------------------------------------------------------------- /client/static/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/favicon.ico -------------------------------------------------------------------------------- /client/static/favicons/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/favicons/mstile-144x144.png -------------------------------------------------------------------------------- /client/static/ic_arrow_back_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/static/ic_delete_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/static/logo-rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/logo-rounded.png -------------------------------------------------------------------------------- /client/static/logo-rounded.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/logo-rounded.webp -------------------------------------------------------------------------------- /client/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/logo.png -------------------------------------------------------------------------------- /client/static/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/logo.webp -------------------------------------------------------------------------------- /client/static/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/logo@2x.png -------------------------------------------------------------------------------- /client/static/logo@2x.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/logo@2x.webp -------------------------------------------------------------------------------- /client/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "NumValidate", 3 | "name": "NumValidate", 4 | "icons": [ 5 | { 6 | "src": "favicons/favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#fff", 14 | "background_color": "#21272f" 15 | } -------------------------------------------------------------------------------- /client/static/robots-development.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /client/static/robots-production.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /dashboard/ 3 | Sitemap: https://numvalidate.com/static/sitemap.xml -------------------------------------------------------------------------------- /client/static/secret-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/secret-blur.png -------------------------------------------------------------------------------- /client/static/secret-blur.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/secret-blur.webp -------------------------------------------------------------------------------- /client/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | https://numvalidate.com/ 11 | 12 | 13 | -------------------------------------------------------------------------------- /client/static/website-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apilayer/numvalidate/5f1556ecb7c7b7f0043526b5a35fbb389e6647ed/client/static/website-screenshot.png -------------------------------------------------------------------------------- /client/types.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export type ApiToken = { 3 | value: string, 4 | name: ?string, 5 | createdAt: number, 6 | updatedAt: number, 7 | }; 8 | 9 | export type Subscription = { 10 | planId: string, 11 | createdAt: string, 12 | updatedAt: string, 13 | }; 14 | 15 | export type CreditCard = { 16 | addressZip: string, 17 | brand: string, 18 | country: string, 19 | expMonth: number, 20 | expYear: number, 21 | last4: string, 22 | createdAt: number, 23 | updatedAt: number, 24 | }; 25 | 26 | export type User = { 27 | id: string, 28 | email: string, 29 | initialized: boolean, 30 | apiTokens: ApiToken[], 31 | subscription: Subscription, 32 | creditCard?: CreditCard, 33 | }; 34 | -------------------------------------------------------------------------------- /client/utils/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | const delay = (time: number) => { 3 | // $FlowFixMe 4 | return new Promise(resolve => setTimeout(resolve, time)); 5 | }; 6 | 7 | /** 8 | * Parses a string to an integer. 9 | * Useful for converting environment variable (while maintaing the 0 values). 10 | * @param {Number|String} input The string to convert to integer. 11 | * @param {Number} defaultOutput Returned if the string is not a valid number. 12 | * @return {Promise} The generated Promise. 13 | */ 14 | const toInt = (input: ?string | number, defaultOutput: number): number => { 15 | if (typeof input === 'number') { 16 | return input; 17 | } 18 | if (input !== undefined && input !== null && !isNaN(input)) { 19 | return Number.parseInt(input, 10); 20 | } else { 21 | return defaultOutput; 22 | } 23 | }; 24 | 25 | export default { 26 | delay, 27 | toInt, 28 | }; 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | redis: 5 | image: redis 6 | ports: 7 | - "6379:6379" 8 | web: 9 | # builds the image based on ./Dockerfile 10 | build: . 11 | # command that will start the app 12 | command: yarn start-dev 13 | # mounts paths between the host and the container 14 | volumes: 15 | # mounts the root directory to the working directory in the container 16 | - .:/app 17 | # if you don't add node_modules, 'yarn' or 'npm i' won't work correctly 18 | - /app/node_modules 19 | ports: 20 | - 1337:1337 21 | depends_on: 22 | - redis 23 | env_file: 24 | - ./client/.env.client 25 | - ./server/.env.server -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "numvalidate-app", 3 | "version": "1.0.0", 4 | "description": "Phone number validation REST API", 5 | "main": "index.js", 6 | "author": "Mazzarolo Matteo", 7 | "license": "Apache-2.0", 8 | "engines": { 9 | "node": "^8.0.0" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/mmazzarolo/numvalidate-app.git" 14 | }, 15 | "keywords": [ 16 | "numvalidate", 17 | "phone", 18 | "number", 19 | "validation", 20 | "formatting", 21 | "rest", 22 | "api" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/mmazzarolo/numvalidate-app/issues" 26 | }, 27 | "homepage": "https://github.com/mmazzarolo/numvalidate-app#readme", 28 | "scripts": { 29 | "start": "NODE_ENV=production node server/index.js", 30 | "heroku-postbuild": "NODE_ENV=production npm run build", 31 | "build": "NODE_ENV=production next build client", 32 | "start-dev": "node server/index.js", 33 | "flow": "flow client", 34 | "lint": "eslint client server", 35 | "precommit": "lint-staged", 36 | "test": "jest server --runInBand --noStackTrace --forceExit", 37 | "test:watch": "npm run test -- --watch", 38 | "reset": "rm -rf node_modules/ && npm prune && yarn cache clean" 39 | }, 40 | "dependencies": { 41 | "auth0-js": "8.12.1", 42 | "autoprefixer": "7.1.4", 43 | "babel-plugin-inline-dotenv": "1.1.1", 44 | "babel-plugin-transform-inline-environment-variables": "0.2.0", 45 | "babel-plugin-wrap-in-js": "1.1.1", 46 | "compression": "1.7.0", 47 | "cssnano": "3.10.0", 48 | "cssnext": "1.8.4", 49 | "date-fns": "1.28.5", 50 | "dotenv": "4.0.0", 51 | "google-libphonenumber": "3.0.4", 52 | "ioredis": "3.1.4", 53 | "js-cookie": "2.1.4", 54 | "jsonwebtoken": "8.0.1", 55 | "jwks-rsa": "1.2.0", 56 | "koa": "2.3.0", 57 | "koa-body": "2.3.0", 58 | "koa-bouncer": "6.0.0", 59 | "koa-connect": "2.0.0", 60 | "koa-helmet": "3.2.0", 61 | "koa-jwt": "3.2.2", 62 | "koa-logger": "3.0.1", 63 | "koa-router": "7.2.1", 64 | "koa-sendfile": "2.0.0", 65 | "libphonenumber-js": "0.4.29", 66 | "ms": "2.0.0", 67 | "next": "3.2.2", 68 | "postcss": "6.0.11", 69 | "postcss-cssnext": "3.0.2", 70 | "postcss-custom-properties": "6.1.0", 71 | "postcss-easy-import": "3.0.0", 72 | "postcss-loader": "2.0.6", 73 | "ratelimiter": "3.0.3", 74 | "raven": "2.1.2", 75 | "raw-loader": "0.5.1", 76 | "react": "^15.6.1", 77 | "react-dom": "^15.6.1", 78 | "react-ga": "2.2.0", 79 | "react-stripe-checkout": "2.6.3", 80 | "request": "2.81.0", 81 | "request-promise": "4.2.1", 82 | "stripe": "5.0.0", 83 | "typed.js": "2.0.5", 84 | "uuid": "3.1.0", 85 | "winston": "2.3.1", 86 | "winston-papertrail": "1.0.5" 87 | }, 88 | "devDependencies": { 89 | "babel-jest": "21.0.2", 90 | "eslint": "4.6.1", 91 | "eslint-plugin-react-app": "1.0.2", 92 | "flow-bin": "0.54.1", 93 | "husky": "0.14.3", 94 | "jest": "21.0.2", 95 | "lint-staged": "4.1.3", 96 | "nodemon": "1.12.0", 97 | "prettier": "1.6.1", 98 | "supertest": "3.0.0" 99 | }, 100 | "lint-staged": { 101 | "server/**/*.js client/**/*.js": [ 102 | "eslint", 103 | "prettier --write", 104 | "git add" 105 | ] 106 | }, 107 | "prettier": { 108 | "print-width": 100, 109 | "trailing-comma": "es5", 110 | "single-quote": true 111 | }, 112 | "jest": { 113 | "verbose": true, 114 | "transform": { 115 | "^.+\\.js$": "babel-jest" 116 | }, 117 | "globals": { 118 | "NODE_ENV": "test" 119 | }, 120 | "moduleFileExtensions": [ 121 | "js", 122 | "jsx" 123 | ], 124 | "moduleDirectories": [ 125 | "node_modules" 126 | ] 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /server/__tests__/utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | toInt, 3 | promisify, 4 | getObjectKey, 5 | generateApiToken, 6 | isApiTokenValid, 7 | } from '../utils/common'; 8 | 9 | describe('toInt', () => { 10 | it('should parse a string to an integer', () => { 11 | expect(toInt(5)).toBe(5); 12 | expect(toInt('6')).toBe(6); 13 | expect(toInt(null, 7)).toBe(7); 14 | }); 15 | }); 16 | 17 | describe('promisify', () => { 18 | const cb = (err, info) => { 19 | 20 | } 21 | 22 | const cbTest = (cb) => { 23 | cb(null, 5); 24 | } 25 | 26 | it('should convert a function with a callback to a promise', async () => { 27 | const promisifiedCbTest = promisify(cbTest); 28 | const result = await promisifiedCbTest; 29 | expect(result).toBe(5); 30 | }); 31 | }); 32 | 33 | describe('getObjectKey', () => { 34 | const obj = { 35 | asd: true, 36 | 'a123': true, 37 | 'A123': true, 38 | }; 39 | 40 | it(`should return the first key of the object that matches the given key without 41 | considering object's property's case`, () => { 42 | expect(getObjectKey(obj, 'asd')).toBe('asd'); 43 | expect(getObjectKey(obj, 'a123')).toBe('a123'); 44 | expect(getObjectKey(obj, 'qwerty')).toBe(undefined); 45 | }); 46 | }); 47 | 48 | describe('generateApiToken', () => { 49 | it('should generate an API token of 32 characters', () => { 50 | const a = generateApiToken(); 51 | const b = generateApiToken(); 52 | expect(typeof a).toBe('string'); 53 | expect(typeof b).toBe('string'); 54 | expect(a.length).toBe(32); 55 | expect(b.length).toBe(32); 56 | expect(a).not.toBe(b); 57 | }); 58 | }); 59 | 60 | describe('isApiTokenValid', () => { 61 | it('should check if the token provided is valid', () => { 62 | expect(isApiTokenValid(generateApiToken())).toBe(true); 63 | expect(isApiTokenValid('abc')).toBe(false); 64 | expect(isApiTokenValid(123)).toBe(false); 65 | expect(isApiTokenValid(NaN)).toBe(false); 66 | expect(isApiTokenValid('abcdefghijklmnopqrstuvwxyz012345')).toBe(false); 67 | }); 68 | }); -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const bodyMiddleware = require('koa-body'); 3 | const helmetMiddleware = require('koa-helmet'); 4 | const koaBouncer = require('koa-bouncer'); 5 | const koaRouter = require('koa-router')(); 6 | const koaConnect = require('koa-connect'); 7 | const sendfile = require('koa-sendfile'); 8 | const compression = require('compression'); 9 | const next = require('next'); 10 | const getIpAddressMiddleware = require('./middlewares/getIpAddress'); 11 | const loggerMiddleware = require('./middlewares/requestLogger'); 12 | const checkAuthTokenMiddleware = require('./middlewares/checkAuthToken'); 13 | const checkMaxRequestsMiddleware = require('./middlewares/checkMaxRequests'); 14 | const checkStripeCustomerMiddleware = require('./middlewares/checkStripeCustomer'); 15 | const fetchUserFromAuth0Middleware = require('./middlewares/fetchUserFromAuth0'); 16 | const auth0TokenGeneratorMiddleware = require('./middlewares/auth0TokenGenerator'); 17 | const errorHandlerMiddleware = require('./middlewares/errorHandler'); 18 | const allowCrossDomainMiddleware = require('./middlewares/allowCrossDomain'); 19 | const checkApiTokenMiddleware = require('./middlewares/checkApiToken'); 20 | const rateLimiterMiddleware = require('./middlewares/rateLimiter'); 21 | const userRoutes = require('./routes/user'); 22 | const apiRoutes = require('./routes/api'); 23 | const keys = require('./config/keys'); 24 | 25 | const app = new Koa(); 26 | const nextApp = next({ dev: keys.IS_ENV_DEVELOPMENT, dir: './client' }); 27 | 28 | app.poweredBy = false; 29 | 30 | if (!keys.IS_ENV_TEST) { 31 | app.use(loggerMiddleware); 32 | } 33 | app.use(koaConnect(compression())); 34 | app.use(helmetMiddleware()); 35 | app.use(allowCrossDomainMiddleware); 36 | app.use(getIpAddressMiddleware); 37 | app.use(errorHandlerMiddleware); 38 | app.use(bodyMiddleware()); 39 | app.use(koaBouncer.middleware()); 40 | app.use(auth0TokenGeneratorMiddleware); 41 | 42 | const handle = nextApp.getRequestHandler(); 43 | 44 | nextApp.prepare().then(() => { 45 | koaRouter 46 | .get( 47 | '/api/validate', 48 | checkApiTokenMiddleware, 49 | checkMaxRequestsMiddleware, 50 | rateLimiterMiddleware, 51 | apiRoutes.validate 52 | ) 53 | .get('/api/countries', checkApiTokenMiddleware, rateLimiterMiddleware, apiRoutes.getCountries) 54 | .get('/user', checkAuthTokenMiddleware, fetchUserFromAuth0Middleware, userRoutes.getUser) 55 | .post( 56 | '/user/customer', 57 | checkAuthTokenMiddleware, 58 | fetchUserFromAuth0Middleware, 59 | userRoutes.createUserCustomer 60 | ) 61 | .post( 62 | '/user/token', 63 | checkAuthTokenMiddleware, 64 | fetchUserFromAuth0Middleware, 65 | userRoutes.createUserApiToken 66 | ) 67 | .delete( 68 | '/user/token/:tokenValue', 69 | checkAuthTokenMiddleware, 70 | fetchUserFromAuth0Middleware, 71 | userRoutes.deleteUserApiToken 72 | ) 73 | .patch( 74 | '/user/subscription', 75 | checkAuthTokenMiddleware, 76 | fetchUserFromAuth0Middleware, 77 | checkStripeCustomerMiddleware, 78 | userRoutes.updateUserSubscription 79 | ) 80 | .patch( 81 | '/user/source', 82 | checkAuthTokenMiddleware, 83 | fetchUserFromAuth0Middleware, 84 | checkStripeCustomerMiddleware, 85 | userRoutes.updateUserSource 86 | ) 87 | .get('/robots.txt', async (ctx, next) => { 88 | if (keys.EXECUTION_ENV === 'production') { 89 | await sendfile(ctx, './client/static/robots-production.txt'); 90 | } else { 91 | await sendfile(ctx, './client/static/robots-development.txt'); 92 | } 93 | }) 94 | .get('/dashboard', async (ctx, next) => { 95 | ctx.res.statusCode = 200; 96 | await nextApp.render(ctx.req, ctx.res, '/Dashboard', ctx.query); 97 | ctx.respond = false; 98 | }) 99 | .get('/', async (ctx, next) => { 100 | ctx.res.statusCode = 200; 101 | nextApp.render(ctx.req, ctx.res, '/Home', ctx.query); 102 | ctx.respond = false; 103 | }) 104 | .get('*', async (ctx, next) => { 105 | ctx.res.statusCode = 200; 106 | await handle(ctx.req, ctx.res); 107 | ctx.respond = false; 108 | }); 109 | 110 | app.use(koaRouter.routes()); 111 | 112 | app.use(koaRouter.allowedMethods()); 113 | }); 114 | module.exports = app; 115 | -------------------------------------------------------------------------------- /server/config/keys.js: -------------------------------------------------------------------------------- 1 | const ms = require('ms'); 2 | const commonUtils = require('../utils/common.js'); 3 | 4 | const NODE_ENV = process.env.NODE_ENV || 'development'; 5 | const IS_ENV_DEVELOPMENT = NODE_ENV === 'development'; 6 | const IS_ENV_PRODUCTION = NODE_ENV === 'production'; 7 | const IS_ENV_TEST = NODE_ENV === 'test'; 8 | 9 | const PORT = commonUtils.toInt(process.env.PORT, 3000); 10 | 11 | const AUTH0_MANAGEMENT_API_AUDIENCE = process.env.AUTH0_MANAGEMENT_API_AUDIENCE; 12 | const AUTH0_MANAGEMENT_API_CLIENT_ID = process.env.AUTH0_MANAGEMENT_API_CLIENT_ID; 13 | const AUTH0_MANAGEMENT_API_CLIENT_SECRET = process.env.AUTH0_MANAGEMENT_API_CLIENT_SECRET; 14 | const AUTH0_AUDIENCE = process.env.AUTH0_AUDIENCE; 15 | const AUTH0_ISSUER = process.env.AUTH0_ISSUER; 16 | const AUTH0_JWKS_URI = process.env.AUTH0_JWKS_URI; 17 | const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN; 18 | 19 | const REDIS_URL = process.env.REDIS_URL; 20 | 21 | const STRIPE_PUBLIC_KEY = process.env.STRIPE_PUBLIC_KEY; 22 | const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; 23 | const STRIPE_FREE_PLAN_ID = process.env.STRIPE_FREE_PLAN_ID; 24 | const STRIPE_PRO_PLAN_ID = process.env.STRIPE_PRO_PLAN_ID; 25 | 26 | const REDIS_CACHE_EXPIRY_IN_MS = commonUtils.toInt(process.env.REDIS_CACHE_EXPIRY_IN_MS, ms('1d')); 27 | 28 | const MAX_API_TOKENS_PER_USER = commonUtils.toInt(process.env.MAX_API_TOKENS_PER_USER, 5); 29 | const RATE_LIMIT_WINDOW_IN_MS = commonUtils.toInt(process.env.RATE_LIMIT_WINDOW_IN_MS || ms('1d')); 30 | const RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS = commonUtils.toInt( 31 | process.env.RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS, 32 | 100 33 | ); 34 | const RATE_LIMIT_FOR_FREE_USER_REQUESTS = commonUtils.toInt( 35 | process.env.RATE_LIMIT_FOR_FREE_USER_REQUESTS, 36 | 1000 37 | ); 38 | const RATE_LIMIT_FOR_PRO_USER_REQUESTS = commonUtils.toInt( 39 | process.env.RATE_LIMIT_FOR_PRO_USER_REQUESTS, 40 | 10000 41 | ); 42 | 43 | const SENTRY_DSN = process.env.SENTRY_DSN; 44 | 45 | const EXECUTION_ENV = process.env.EXECUTION_ENV || 'development'; 46 | 47 | const PAPERTRAIL_HOST = process.env.PAPERTRAIL_HOST; 48 | const PAPERTRAIL_PORT = commonUtils.toInt(process.env.PAPERTRAIL_PORT, 9000); 49 | 50 | module.exports = { 51 | AUTH0_MANAGEMENT_API_AUDIENCE, 52 | IS_ENV_DEVELOPMENT, 53 | IS_ENV_PRODUCTION, 54 | IS_ENV_TEST, 55 | PORT, 56 | AUTH0_AUDIENCE, 57 | AUTH0_ISSUER, 58 | AUTH0_JWKS_URI, 59 | AUTH0_DOMAIN, 60 | AUTH0_MANAGEMENT_API_CLIENT_ID, 61 | AUTH0_MANAGEMENT_API_CLIENT_SECRET, 62 | REDIS_URL, 63 | STRIPE_PUBLIC_KEY, 64 | STRIPE_SECRET_KEY, 65 | STRIPE_FREE_PLAN_ID, 66 | STRIPE_PRO_PLAN_ID, 67 | REDIS_CACHE_EXPIRY_IN_MS, 68 | MAX_API_TOKENS_PER_USER, 69 | RATE_LIMIT_WINDOW_IN_MS, 70 | RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS, 71 | RATE_LIMIT_FOR_FREE_USER_REQUESTS, 72 | RATE_LIMIT_FOR_PRO_USER_REQUESTS, 73 | SENTRY_DSN, 74 | EXECUTION_ENV, 75 | PAPERTRAIL_HOST, 76 | PAPERTRAIL_PORT, 77 | }; 78 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '.env.server' }); 2 | const keys = require('./config/keys'); 3 | const app = require('./app'); 4 | 5 | app.listen(keys.PORT, () => { 6 | console.log('Listening on port ', keys.PORT); 7 | }); 8 | 9 | module.exports = app; 10 | -------------------------------------------------------------------------------- /server/middlewares/allowCrossDomain.js: -------------------------------------------------------------------------------- 1 | module.exports = async (ctx, next) => { 2 | ctx.set('Access-Control-Allow-Origin', '*'); 3 | ctx.set('Access-Control-Allow-Methods', 'GET,PUT,POST,PATCH,DELETE,OPTIONS'); 4 | ctx.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-App-Api-Token'); 5 | if (ctx.method === 'OPTIONS') { 6 | ctx.status = 200; 7 | return; 8 | } 9 | return next(); 10 | }; 11 | -------------------------------------------------------------------------------- /server/middlewares/auth0TokenGenerator.js: -------------------------------------------------------------------------------- 1 | const commonUtils = require('../utils/common'); 2 | const auth0Service = require('../services/auth0'); 3 | 4 | module.exports = async (ctx, next) => { 5 | if (ctx.app.auth0Token && !commonUtils.isJwtTokenExpired(ctx.app.auth0Token)) { 6 | return await next(); 7 | } 8 | console.info('Generating a new Auth0 token'); 9 | const auth0Token = await auth0Service.generateAccessToken(); 10 | console.info('New Auth0 token generated successfully'); 11 | ctx.app.auth0Token = auth0Token; 12 | return next(); 13 | }; 14 | -------------------------------------------------------------------------------- /server/middlewares/checkApiToken.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const commonUtils = require('../utils/common'); 3 | const redisService = require('../services/redis'); 4 | const auth0Service = require('../services/auth0'); 5 | 6 | module.exports = async (ctx, next) => { 7 | const apiTokenHeader = commonUtils.getObjectKey(ctx.headers, 'x-api-token'); 8 | console.info('Has API token? ', apiTokenHeader !== undefined); 9 | if (!apiTokenHeader) { 10 | console.info('Missing API token'); 11 | return next(); 12 | } 13 | const apiToken = ctx.headers[apiTokenHeader]; 14 | console.info('API token? ', apiToken); 15 | if (!commonUtils.isApiTokenValid(apiToken)) { 16 | console.info('Invalid API token'); 17 | ctx.throw(400, 'Invalid API token'); 18 | } 19 | let userId = await redisService.getUserIdByApiToken(apiToken); 20 | const isApiTokenCached = !_.isNil(userId); 21 | console.info('Is API token cached? ', isApiTokenCached); 22 | if (!isApiTokenCached) { 23 | const user = await auth0Service.getUserByApiToken(ctx.app.auth0Token, apiToken); 24 | console.info('Is API token assigned to an user? ', user !== undefined); 25 | if (!user) { 26 | ctx.throw(400, 'Invalid API token'); 27 | } 28 | userId = user.user_id; 29 | await redisService.setApiToken(apiToken, userId); 30 | } 31 | console.info('User id: ', userId); 32 | ctx.state.apiToken = apiToken; 33 | ctx.state.userId = userId; 34 | return next(); 35 | }; 36 | -------------------------------------------------------------------------------- /server/middlewares/checkAuthToken.js: -------------------------------------------------------------------------------- 1 | const jwt = require('koa-jwt'); 2 | const { koaJwtSecret } = require('jwks-rsa'); 3 | const keys = require('../config/keys'); 4 | 5 | module.exports = async (ctx, next) => { 6 | const jwtMiddleware = await jwt({ 7 | secret: koaJwtSecret({ 8 | cache: true, 9 | rateLimit: true, 10 | jwksRequestsPerMinute: 5, 11 | jwksUri: keys.AUTH0_JWKS_URI, 12 | }), 13 | audience: keys.AUTH0_AUDIENCE, 14 | issuer: keys.AUTH0_ISSUER, 15 | algorithms: ['RS256'], 16 | }); 17 | return await jwtMiddleware(ctx, next); 18 | }; 19 | -------------------------------------------------------------------------------- /server/middlewares/checkMaxRequests.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const auth0Service = require('../services/auth0'); 3 | const redisService = require('../services/redis'); 4 | const stripeService = require('../services/stripe'); 5 | const keys = require('../config/keys'); 6 | 7 | module.exports = async (ctx, next) => { 8 | if (!ctx.state.apiToken) { 9 | ctx.state.clientRequestsLimit = keys.RATE_LIMIT_FOR_UNAUTHENTICATED_REQUESTS; 10 | } else { 11 | const userId = ctx.state.userId; 12 | const cachedMaxRequests = await redisService.getMaxRequestsByUserId(userId); 13 | console.log('Is daily usage limit cached? ', !_.isNil(cachedMaxRequests)); 14 | if (!_.isNil(cachedMaxRequests)) { 15 | ctx.state.clientRequestsLimit = cachedMaxRequests; 16 | return next(); 17 | } else { 18 | const auth0User = await auth0Service.getUserById(ctx.app.auth0Token, userId); 19 | const customerId = auth0User.app_metadata.stripe_customer.id; 20 | const stripeCustomer = await stripeService.fetchCustomer(customerId); 21 | const currentSubscriptionPlanId = stripeService.getCurrentSubscriptionPlanId(stripeCustomer); 22 | const isCurrentSubscriptionActive = stripeService.isCurrentSubscriptionActive(stripeCustomer); 23 | let maxRequests; 24 | const isFreePlan = 25 | currentSubscriptionPlanId && currentSubscriptionPlanId === keys.STRIPE_FREE_PLAN_ID; 26 | const isPaidPlan = 27 | currentSubscriptionPlanId && currentSubscriptionPlanId === keys.STRIPE_PRO_PLAN_ID; 28 | if (!isCurrentSubscriptionActive) { 29 | maxRequests = keys.RATE_LIMIT_FOR_FREE_USER_REQUESTS; 30 | } else if (isFreePlan) { 31 | maxRequests = keys.RATE_LIMIT_FOR_FREE_USER_REQUESTS; 32 | } else if (isPaidPlan) { 33 | maxRequests = keys.RATE_LIMIT_FOR_PRO_USER_REQUESTS; 34 | } else { 35 | ctx.throw(500, `Invalid subscription plan id: ${currentSubscriptionPlanId}`); 36 | } 37 | await redisService.setUserMaxRequests(userId, maxRequests); 38 | ctx.state.clientRequestsLimit = maxRequests; 39 | } 40 | } 41 | console.log('Daily usage limit: ', ctx.state.clientRequestsLimit); 42 | return next(); 43 | }; 44 | -------------------------------------------------------------------------------- /server/middlewares/checkStripeCustomer.js: -------------------------------------------------------------------------------- 1 | module.exports = async (ctx, next) => { 2 | if (!ctx.state.auth0User.app_metadata.stripe_customer.id) { 3 | ctx.throw(400, 'User not activated yet.'); 4 | } 5 | return next(); 6 | }; 7 | -------------------------------------------------------------------------------- /server/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | const koaBouncer = require('koa-bouncer'); 2 | const isNumber = require('lodash').isNumber; 3 | 4 | module.exports = async (ctx, next) => { 5 | try { 6 | await next(); 7 | } catch (err) { 8 | ctx.type = 'application/json'; 9 | ctx.body = {}; 10 | if (err instanceof koaBouncer.ValidationError) { 11 | ctx.status = 422; 12 | ctx.body.error = { 13 | message: err.message || 'Validation error', 14 | status: 422, 15 | }; 16 | } else { 17 | const status = isNumber(err.status) ? err.status : 500; 18 | ctx.state.error = err; 19 | ctx.status = status; 20 | ctx.body.error = { 21 | message: err.message || 'Internal server error', 22 | status: status, 23 | }; 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /server/middlewares/fetchUserFromAuth0.js: -------------------------------------------------------------------------------- 1 | const auth0Service = require('../services/auth0'); 2 | 3 | module.exports = async (ctx, next) => { 4 | const { auth0Token } = ctx.app; 5 | const userId = ctx.state.user.sub; 6 | const user = await auth0Service.getUserById(auth0Token, userId); 7 | if (!user) { 8 | ctx.throw(404, 'User not found.'); 9 | } 10 | ctx.state.auth0User = user; 11 | return next(); 12 | }; 13 | -------------------------------------------------------------------------------- /server/middlewares/getIpAddress.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | module.exports = async (ctx, next) => { 4 | let ipAddress; 5 | const headerForwardedTo = ctx.req.headers['x-forwarded-for']; 6 | if (headerForwardedTo) { 7 | ipAddress = _.last(headerForwardedTo.split(',')); 8 | } else { 9 | ipAddress = ctx.ip; 10 | } 11 | ctx.state.ipAddress = ipAddress; 12 | return next(); 13 | }; 14 | -------------------------------------------------------------------------------- /server/middlewares/rateLimiter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Docs: https://github.com/koajs/ratelimit/blob/master/index.js 3 | */ 4 | const RateLimiter = require('ratelimiter'); 5 | const ms = require('ms'); 6 | const redisService = require('../services/redis'); 7 | const commonUtils = require('../utils/common'); 8 | const keys = require('../config/keys'); 9 | 10 | module.exports = async (ctx, next) => { 11 | let id; 12 | if (ctx.state.apiToken) { 13 | id = `user_id:${ctx.state.userId}`; 14 | } else { 15 | id = `ip:${ctx.state.ipAddress}`; 16 | } 17 | 18 | const limiter = new RateLimiter( 19 | Object.assign({ 20 | db: redisService.getRedis(), 21 | id: id, // the identifier to limit against (api token or IP) 22 | duration: keys.API_REQUESTS_EXPIRY_IN_MS, // duration of limit in milliseconds 23 | max: ctx.state.clientRequestsLimit, // max requests within duration 24 | }) 25 | ); 26 | 27 | const limit = await commonUtils.promisify(limiter.get.bind(limiter)); 28 | 29 | const remaining = limit.remaining > 0 ? limit.remaining - 1 : 0; 30 | const reset = limit.reset; 31 | const total = limit.total; 32 | const headers = { 33 | 'X-RateLimit-Remaining': remaining, 34 | 'X-RateLimit-Reset': reset, 35 | 'X-RateLimit-Limit': total, 36 | }; 37 | 38 | ctx.set(headers); 39 | console.info('Request by %s', id); 40 | console.info('Remaining requests: %s/%s (reset at %s)', remaining, total, Date(reset)); 41 | 42 | if (limit.remaining) { 43 | return await next(); 44 | } 45 | 46 | const delta = (limit.reset * 1000 - Date.now()) | 0; 47 | const after = (limit.reset - Date.now() / 1000) | 0; 48 | ctx.set('Retry-After', after); 49 | 50 | ctx.status = 429; 51 | ctx.body = `Rate limit exceeded, retry in ${ms(delta, { long: true })}.`; 52 | 53 | ctx.throw(ctx.status, ctx.body, { headers: headers }); 54 | }; 55 | -------------------------------------------------------------------------------- /server/middlewares/requestLogger.js: -------------------------------------------------------------------------------- 1 | const uuid = require('uuid'); 2 | const chalk = require('chalk'); 3 | const _ = require('lodash'); 4 | const keys = require('../config/keys'); 5 | const logger = require('../utils/logger'); 6 | const sentryService = require('../services/sentry'); 7 | 8 | const STATUS_COLORS = { 9 | error: 'red', 10 | warn: 'yellow', 11 | info: 'green', 12 | }; 13 | 14 | const REQUEST_WHITE_LIST = [ 15 | 'url', 16 | 'headers', 17 | 'method', 18 | 'ip', 19 | 'protocol', 20 | 'originalUrl', 21 | 'query', 22 | 'body', 23 | 'cookies', 24 | 'query_string', 25 | ]; 26 | 27 | const BODY_BLACK_LIST = ['password']; 28 | 29 | const levelFromStatus = status => { 30 | switch (true) { 31 | case status >= 500: 32 | return 'error'; 33 | case status >= 400: 34 | return 'warn'; 35 | case status >= 100: 36 | return 'info'; 37 | default: 38 | return 'error'; 39 | } 40 | }; 41 | 42 | const filterObject = (originalObj, whiteList, bodyBlacklist) => { 43 | const newObj = _.pick(originalObj, whiteList); 44 | if (newObj.body) { 45 | newObj.body = _.omit(newObj.body, bodyBlacklist); 46 | } 47 | return newObj; 48 | }; 49 | 50 | module.exports = async (ctx, next) => { 51 | if (!ctx.originalUrl.startsWith('/api') && !ctx.originalUrl.startsWith('/user')) { 52 | return await next(); 53 | } 54 | const start = new Date(); 55 | ctx.uuid = uuid.v4(); 56 | 57 | await next(); 58 | 59 | ctx.responseTime = new Date() - start; 60 | 61 | const level = levelFromStatus(ctx.status); 62 | 63 | const msg = 64 | chalk.gray(`${ctx.method} ${ctx.originalUrl}`) + 65 | chalk[STATUS_COLORS[level]](` ${ctx.status} `) + 66 | chalk.gray(`${ctx.responseTime}ms `) + 67 | chalk.red(_.get(ctx, 'body.error.message', '')); 68 | 69 | logger[level](msg); 70 | 71 | if (keys.SENTRY_DSN && ctx.status >= 400) { 72 | const sentryMsg = `${ctx.status} ${ctx.method} ${ctx.originalUrl} ${_.get( 73 | ctx, 74 | 'body.error.message', 75 | '' 76 | )}`; 77 | const user = { 78 | id: _.get(ctx.state, 'auth0User.user_id') || _.get(ctx.state, 'userId'), 79 | username: _.get(ctx.state, 'apiToken') || _.get(ctx.state, 'ipAddress'), 80 | email: _.get(ctx.state, 'auth0User.email'), 81 | }; 82 | const meta = { 83 | req: _.get(sentryService.parseRequest(ctx.request), 'request', undefined), 84 | level: ctx.status >= 500 ? 'error' : 'warning', 85 | user: user, 86 | extra: { 87 | user: user, 88 | id: ctx.uuid, 89 | timestamp: new Date(), 90 | method: ctx.method, 91 | originalUrl: ctx.originalUrl, 92 | status: ctx.status, 93 | responseTime: ctx.responseTime, 94 | req: filterObject(ctx.request, REQUEST_WHITE_LIST, BODY_BLACK_LIST), 95 | ipAddress: _.get(ctx.state, 'ipAddress'), 96 | clientRequestsLimit: _.get(ctx.state, 'clientRequestsLimit'), 97 | stack: _.get(ctx.state, 'error.stack'), 98 | message: _.get('ctx.body.error.message') || _.get(ctx.state, 'error.message'), 99 | }, 100 | }; 101 | sentryService.log(sentryMsg, meta); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /server/routes/api.js: -------------------------------------------------------------------------------- 1 | const PNF = require('google-libphonenumber').PhoneNumberFormat; 2 | const phoneUtil = require('google-libphonenumber').PhoneNumberUtil.getInstance(); 3 | const countries = require('../static/countries.json'); 4 | 5 | /** 6 | * GET /api/countries 7 | * 8 | * @return {Object} The countries supported by this API. 9 | */ 10 | exports.getCountries = async ctx => { 11 | ctx.body = { data: countries }; 12 | }; 13 | 14 | /** 15 | * GET /api/validate 16 | * 17 | * @param {String} number The phone number to validate. 18 | * @param {String} countryCode The country code of phone number to validate. 19 | * @return {Object} The validation result. 20 | */ 21 | exports.validate = async (ctx, next) => { 22 | ctx 23 | .validateQuery('number') 24 | .required() 25 | .isString(); 26 | ctx 27 | .validateQuery('countryCode') 28 | .optional() 29 | .isString(); 30 | const { number, countryCode } = ctx.vals; 31 | let numberToCheck = !countryCode ? `+${number}` : number; 32 | let result = { 33 | valid: false, 34 | number: undefined, 35 | e164Format: undefined, 36 | internationalFormat: undefined, 37 | nationalFormat: undefined, 38 | countryCode: undefined, 39 | countryPrefix: undefined, 40 | countryName: undefined, 41 | }; 42 | result.number = number; 43 | result.countryCode = countryCode; 44 | try { 45 | const parsedNumber = phoneUtil.parseAndKeepRawInput(numberToCheck, countryCode); 46 | result.valid = phoneUtil.isValidNumber(parsedNumber); 47 | if (result.valid) { 48 | result.number = number; 49 | result.e164Format = phoneUtil.format(parsedNumber, PNF.E164); 50 | result.internationalFormat = phoneUtil.format(parsedNumber, PNF.INTERNATIONAL); 51 | result.nationalFormat = phoneUtil.format(parsedNumber, PNF.NATIONAL); 52 | result.countryCode = phoneUtil.getRegionCodeForNumber(parsedNumber); 53 | result.countryPrefix = countries[result.countryCode].countryPrefix; 54 | result.countryName = countries[result.countryCode].countryName; 55 | } 56 | } catch (err) { 57 | console.log('Parsing error: ', err); 58 | } 59 | 60 | ctx.body = { data: result }; 61 | }; 62 | -------------------------------------------------------------------------------- /server/routes/user.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const commonUtils = require('../utils/common'); 3 | const auth0Service = require('../services/auth0'); 4 | const redisService = require('../services/redis'); 5 | const stripeService = require('../services/stripe'); 6 | const keys = require('../config/keys'); 7 | 8 | const normalizeUser = user => { 9 | const appMetadata = user.app_metadata || {}; 10 | const apiTokens = appMetadata.api_tokens || {}; 11 | const stripeSubscription = appMetadata.stripe_subscription || {}; 12 | const stripeSource = appMetadata.stripe_source || {}; 13 | return { 14 | id: user.user_id, 15 | email: user.email, 16 | initialized: appMetadata.initialized, 17 | apiTokens: Object.values(apiTokens).map(apiToken => ({ 18 | value: apiToken.value, 19 | name: apiToken.name, 20 | createdAt: apiToken.created_at, 21 | })), 22 | subscription: { 23 | planId: stripeSubscription.plan_id, 24 | createdAt: stripeSubscription.created_at, 25 | updatedAt: stripeSubscription.updated_at, 26 | }, 27 | creditCard: { 28 | addressZip: stripeSource.address_zip, 29 | brand: stripeSource.brand, 30 | country: stripeSource.country, 31 | expMonth: stripeSource.exp_month, 32 | expYear: stripeSource.exp_year, 33 | last4: stripeSource.last4, 34 | createdAt: stripeSource.created_at, 35 | }, 36 | }; 37 | }; 38 | 39 | /** 40 | * GET /user 41 | * 42 | * Gets the authenticated user. 43 | * @return {User} The authenticated user. 44 | */ 45 | exports.getUser = async ctx => { 46 | const user = ctx.state.auth0User; 47 | ctx.body = { data: normalizeUser(user) }; 48 | }; 49 | 50 | /** 51 | * POST /user/customer 52 | * 53 | * Creates a Stripe customer and subscribe the user to the free tier. 54 | * @return {User} The updated user. 55 | */ 56 | exports.createUserCustomer = async ctx => { 57 | const { auth0Token } = ctx.app; 58 | const user = ctx.state.auth0User; 59 | const userId = user.user_id; 60 | const userAppMetadata = user.app_metadata || {}; 61 | if (userAppMetadata.initialized) { 62 | ctx.throw(409, 'User has already been activated'); 63 | } 64 | const currentDate = Date.now(); 65 | const stripeCustomer = await stripeService.createCustomer({ 66 | email: user.email, 67 | }); 68 | const stripePlans = await stripeService.fetchPlans(); 69 | const freePlan = stripePlans.data.find(x => x.amount === 0); 70 | const stripeSubscription = await stripeService.createSubscription({ 71 | customer: stripeCustomer.id, 72 | plan: freePlan.id, 73 | }); 74 | const apiToken = commonUtils.generateApiToken(); 75 | const newAppMetadata = { 76 | initialized: true, 77 | stripe_customer: { 78 | id: stripeCustomer.id, 79 | created_at: currentDate, 80 | updated_at: currentDate, 81 | }, 82 | stripe_subscription: { 83 | id: stripeSubscription.id, 84 | plan_id: freePlan.id, 85 | created_at: currentDate, 86 | updated_at: currentDate, 87 | }, 88 | api_tokens: { 89 | [apiToken]: { 90 | value: apiToken, 91 | created_at: currentDate, 92 | name: 'Default', 93 | }, 94 | }, 95 | }; 96 | const updatedUser = await auth0Service.updateUserAppMetadata(auth0Token, userId, newAppMetadata); 97 | ctx.body = { data: normalizeUser(updatedUser) }; 98 | }; 99 | 100 | /** 101 | * POST /user/token 102 | * 103 | * Generates a new API token for the authenticated user. 104 | * @param {String} tokenName The token name. 105 | * @return {User} The updated user. 106 | */ 107 | exports.createUserApiToken = async ctx => { 108 | const { auth0Token } = ctx.app; 109 | ctx 110 | .validateBody('tokenName') 111 | .required() 112 | .isString(); 113 | const { tokenName } = ctx.vals; 114 | const user = ctx.state.auth0User; 115 | const userId = user.user_id; 116 | const userAppMetadata = user.app_metadata || {}; 117 | const apiToken = commonUtils.generateApiToken(); 118 | const previousApiTokens = userAppMetadata.api_tokens; 119 | if (Object.keys(previousApiTokens).length >= keys.MAX_API_TOKENS_PER_USER) { 120 | ctx.throw('Maximum number of available tokens reached.'); 121 | } 122 | const currentDate = Date.now(); 123 | const newAppMetadata = { 124 | api_tokens: Object.assign(previousApiTokens, { 125 | [apiToken]: { 126 | value: apiToken, 127 | created_at: currentDate, 128 | name: tokenName, 129 | }, 130 | }), 131 | }; 132 | const updatedUser = await auth0Service.updateUserAppMetadata(auth0Token, userId, newAppMetadata); 133 | await redisService.removeUserCache(userId, Object.keys(previousApiTokens)); 134 | ctx.body = { data: normalizeUser(updatedUser) }; 135 | }; 136 | 137 | /** 138 | * DELETE /user/token 139 | * 140 | * Deletes an user API token. 141 | * @param {String} tokenValue The token value. 142 | * @return {User} The updated user. 143 | */ 144 | exports.deleteUserApiToken = async ctx => { 145 | const { auth0Token } = ctx.app; 146 | ctx 147 | .validateParam('tokenValue') 148 | .required() 149 | .isString(); 150 | const { tokenValue } = ctx.vals; 151 | const user = ctx.state.auth0User; 152 | const userId = user.user_id; 153 | const userAppMetadata = user.app_metadata || {}; 154 | if (!userAppMetadata.api_tokens || !userAppMetadata.api_tokens[tokenValue]) { 155 | ctx.throw(404, 'Token not found'); 156 | } 157 | const previousApiTokens = userAppMetadata.api_tokens; 158 | const newAppMetadata = { 159 | api_tokens: _.omit(previousApiTokens, tokenValue), 160 | }; 161 | const updatedUser = await auth0Service.updateUserAppMetadata(auth0Token, userId, newAppMetadata); 162 | await redisService.removeUserCache(userId, Object.keys(previousApiTokens)); 163 | ctx.body = { data: normalizeUser(updatedUser) }; 164 | }; 165 | 166 | /** 167 | * PATCH /user/subscription 168 | * 169 | * Updates the user subscription plan. 170 | * @param {String} planId The new payment plan. 171 | * @param {String} stripeSource The Stripe source token generated client side by 172 | * the checkout form (must be provided only for paid plans). 173 | * @return {User} The updated user. 174 | */ 175 | exports.updateUserSubscription = async ctx => { 176 | const { auth0Token } = ctx.app; 177 | const stripePlans = await stripeService.fetchPlans(); 178 | ctx 179 | .validateBody('planId') 180 | .required() 181 | .checkPred(id => stripePlans.data.find(x => x.id === id)); 182 | ctx 183 | .validateBody('stripeSource') 184 | .optional() 185 | .isString(); 186 | const { planId, stripeSource } = ctx.vals; 187 | const user = ctx.state.auth0User; 188 | const userId = user.user_id; 189 | const userAppMetadata = user.app_metadata || {}; 190 | const currentDate = Date.now(); 191 | if (userAppMetadata.stripe_subscription.plan_id === planId) { 192 | ctx.throw(409, 'User already has this active subscription'); 193 | } 194 | const freePlan = stripePlans.data.find(x => x.amount === 0); 195 | const requireCreditCard = planId !== freePlan.id; 196 | if (userAppMetadata.stripe_subscription.plan_id === planId) { 197 | ctx.throw(409, 'User already has this active subscription'); 198 | } 199 | if (requireCreditCard && !stripeSource) { 200 | ctx.throw(400, 'The selected plan needs a valid credit card'); 201 | } 202 | let newAppMetadata = {}; 203 | if (requireCreditCard && stripeSource) { 204 | const stripeCustomerId = userAppMetadata.stripe_customer.id; 205 | const updatedStripeCustomer = await stripeService.updateCustomer(stripeCustomerId, { 206 | source: stripeSource, 207 | }); 208 | const stripeCustomerCard = updatedStripeCustomer.sources.data[0]; 209 | const previousStripeCustomer = userAppMetadata.stripe_customer; 210 | newAppMetadata.stripe_customer = Object.assign(previousStripeCustomer, { 211 | updated_at: currentDate, 212 | }); 213 | newAppMetadata.stripe_source = { 214 | updated_at: currentDate, 215 | id: stripeCustomerCard.id, 216 | address_zip: stripeCustomerCard.address_zip, 217 | brand: stripeCustomerCard.brand, 218 | country: stripeCustomerCard.country, 219 | exp_month: stripeCustomerCard.exp_month, 220 | exp_year: stripeCustomerCard.exp_year, 221 | last4: stripeCustomerCard.last4, 222 | created_at: currentDate, 223 | }; 224 | } 225 | const stripeSubscriptionId = userAppMetadata.stripe_subscription.id; 226 | await stripeService.updateSubscription(stripeSubscriptionId, { 227 | plan: planId, 228 | }); 229 | const previousApiTokens = userAppMetadata.api_tokens; 230 | const previousStripeSubscription = userAppMetadata.stripe_subscription; 231 | newAppMetadata.stripe_subscription = Object.assign(previousStripeSubscription, { 232 | plan_id: planId, 233 | updated_at: currentDate, 234 | }); 235 | const updatedUser = await auth0Service.updateUserAppMetadata(auth0Token, userId, newAppMetadata); 236 | if (previousApiTokens && freePlan) { 237 | await redisService.removeUserCache(userId, Object.keys(previousApiTokens)); 238 | } 239 | ctx.body = { data: normalizeUser(updatedUser) }; 240 | }; 241 | 242 | /** 243 | * PATCH /user/source 244 | * 245 | * Updates the user payment info (credit card details). 246 | * @param {String} stripeSource The Stripe source token generated client side by 247 | * the checkout form. 248 | * @return {User} The updated user. 249 | */ 250 | exports.updateUserSource = async ctx => { 251 | const { auth0Token } = ctx.app; 252 | ctx 253 | .validateBody('stripeSource') 254 | .required() 255 | .isString(); 256 | const { stripeSource } = ctx.vals; 257 | const user = ctx.state.auth0User; 258 | const userId = user.user_id; 259 | const userAppMetadata = user.app_metadata || {}; 260 | const currentDate = Date.now(); 261 | const stripeCustomerId = userAppMetadata.stripe_customer.id; 262 | const updatedStripeCustomer = await stripeService.updateCustomer(stripeCustomerId, { 263 | source: stripeSource, 264 | }); 265 | const stripeCustomerCard = updatedStripeCustomer.sources.data[0]; 266 | const previousStripeCustomer = userAppMetadata.stripe_customer; 267 | const newAppMetadata = { 268 | stripe_customer: Object.assign(previousStripeCustomer, { 269 | updated_at: currentDate, 270 | }), 271 | stripe_source: { 272 | updated_at: currentDate, 273 | id: stripeCustomerCard.id, 274 | address_zip: stripeCustomerCard.address_zip, 275 | brand: stripeCustomerCard.brand, 276 | country: stripeCustomerCard.country, 277 | exp_month: stripeCustomerCard.exp_month, 278 | exp_year: stripeCustomerCard.exp_year, 279 | last4: stripeCustomerCard.last4, 280 | created_at: currentDate, 281 | }, 282 | }; 283 | const updatedUser = await auth0Service.updateUserAppMetadata(auth0Token, userId, newAppMetadata); 284 | ctx.body = { data: normalizeUser(updatedUser) }; 285 | }; 286 | -------------------------------------------------------------------------------- /server/services/auth0.js: -------------------------------------------------------------------------------- 1 | const request = require('request-promise'); 2 | const keys = require('../config/keys'); 3 | 4 | const AUTH0_API_URL = `https://${keys.AUTH0_DOMAIN}/api/v2`; 5 | 6 | exports.generateAccessToken = async () => { 7 | const response = await request({ 8 | url: `https://${keys.AUTH0_DOMAIN}/oauth/token`, 9 | method: 'POST', 10 | json: true, 11 | body: { 12 | grant_type: 'client_credentials', 13 | client_id: keys.AUTH0_MANAGEMENT_API_CLIENT_ID, 14 | client_secret: keys.AUTH0_MANAGEMENT_API_CLIENT_SECRET, 15 | audience: keys.AUTH0_MANAGEMENT_API_AUDIENCE, 16 | }, 17 | headers: { 'content-type': 'application/json' }, 18 | }); 19 | return response.access_token; 20 | }; 21 | 22 | exports.getUserByApiToken = async (auth0Token, apiToken) => { 23 | const response = await request({ 24 | url: `${AUTH0_API_URL}/users?q=_exists_:app_metadata.api_tokens.${apiToken}`, 25 | method: 'GET', 26 | json: true, 27 | headers: { 28 | authorization: `Bearer ${auth0Token}`, 29 | 'content-type': 'application/json', 30 | }, 31 | }); 32 | return response && response.length > 0 ? response[0] : undefined; 33 | }; 34 | 35 | exports.getUserById = async (auth0Token, userId) => { 36 | const response = await request({ 37 | url: `${AUTH0_API_URL}/users/${userId}`, 38 | method: 'GET', 39 | json: true, 40 | headers: { 41 | authorization: `Bearer ${auth0Token}`, 42 | 'content-type': 'application/json', 43 | }, 44 | }); 45 | return response; 46 | }; 47 | 48 | exports.updateUserAppMetadata = async (auth0Token, userId, appMetadata) => { 49 | const response = await request({ 50 | url: `${AUTH0_API_URL}/users/${userId}`, 51 | method: 'PATCH', 52 | json: true, 53 | headers: { 54 | authorization: `Bearer ${auth0Token}`, 55 | 'content-type': 'application/json', 56 | }, 57 | body: { 58 | app_metadata: appMetadata, 59 | }, 60 | }); 61 | return response; 62 | }; 63 | -------------------------------------------------------------------------------- /server/services/redis.js: -------------------------------------------------------------------------------- 1 | const Redis = require('ioredis'); 2 | const keys = require('../config/keys'); 3 | 4 | const redis = new Redis(keys.REDIS_URL); 5 | 6 | exports.getRedis = () => { 7 | return redis; 8 | }; 9 | 10 | exports.getMaxRequestsByUserId = async userId => { 11 | const key = `user_id_to_max_requests:${userId}`; 12 | const result = await redis.get(key); 13 | return result; 14 | }; 15 | 16 | exports.setUserMaxRequests = async (userId, maxRequests) => { 17 | const key = `user_id_to_max_requests:${userId}`; 18 | const result = await redis.set(key, maxRequests, 'EX', keys.REDIS_CACHE_EXPIRY_IN_MS); 19 | return result; 20 | }; 21 | 22 | exports.getUserIdByApiToken = async apiToken => { 23 | const key = `token_to_user_id:${apiToken}`; 24 | const result = await redis.get(key); 25 | return result; 26 | }; 27 | 28 | exports.setApiToken = async (apiToken, userId) => { 29 | const key = `token_to_user_id:${apiToken}`; 30 | const result = await redis.set(key, userId, 'EX', keys.REDIS_CACHE_EXPIRY_IN_MS); 31 | return result; 32 | }; 33 | 34 | exports.removeUserCache = async (userId, apiTokens) => { 35 | // TODO: CHECK 36 | const limitKey = `limit:${userId}`; 37 | await redis.del(limitKey); 38 | const maxKey = `user_id_to_max_requests:${userId}`; 39 | await redis.del(maxKey); 40 | if (apiTokens && Array.isArray(apiTokens)) { 41 | for (const apiToken of apiTokens) { 42 | const tokenKey = `token_to_user_id:${apiToken}`; 43 | await redis.del(tokenKey); 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /server/services/sentry.js: -------------------------------------------------------------------------------- 1 | const Raven = require('raven'); 2 | const keys = require('../config/keys'); 3 | 4 | const sentryEnabled = keys.SENTRY_DSN && keys.IS_ENV_PRODUCTION; 5 | 6 | if (sentryEnabled) { 7 | Raven.config(keys.SENTRY_DSN, { 8 | autoBreadcrumbs: true, 9 | captureUnhandledRejections: true, 10 | environment: keys.EXECUTION_ENV, 11 | }).install(); 12 | } 13 | 14 | exports.log = (msg, meta) => { 15 | if (sentryEnabled) { 16 | if (meta.level === 'error' || meta.level === 'fatal') { 17 | Raven.captureException(msg, meta); 18 | } 19 | Raven.captureMessage(msg, meta); 20 | } 21 | }; 22 | 23 | exports.parseRequest = Raven.parsers.parseRequest; 24 | -------------------------------------------------------------------------------- /server/services/stripe.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const Stripe = require('stripe'); 3 | const keys = require('../config/keys'); 4 | 5 | const stripe = new Stripe(keys.STRIPE_SECRET_KEY); 6 | 7 | exports.fetchPlans = async params => { 8 | const plans = stripe.plans.list(); 9 | return plans; 10 | }; 11 | 12 | exports.fetchCustomer = async customerId => { 13 | const customer = await stripe.customers.retrieve(customerId); 14 | return customer; 15 | }; 16 | 17 | exports.createCustomer = async params => { 18 | const customer = await stripe.customers.create(params); 19 | return customer; 20 | }; 21 | 22 | exports.updateCustomer = async (customerId, params) => { 23 | const customer = await stripe.customers.update(customerId, params); 24 | return customer; 25 | }; 26 | 27 | exports.createSubscription = async params => { 28 | const subscription = await stripe.subscriptions.create(params); 29 | return subscription; 30 | }; 31 | 32 | exports.updateSubscription = async (subscriptionId, params) => { 33 | const subscription = await stripe.subscriptions.update(subscriptionId, params); 34 | return subscription; 35 | }; 36 | 37 | exports.getCurrentSubscriptionPlanId = stripeCustomer => { 38 | return _.get(stripeCustomer, 'subscriptions.data.[0].plan.id'); 39 | }; 40 | 41 | exports.isCurrentSubscriptionActive = stripeCustomer => { 42 | const status = _.get(stripeCustomer, 'subscriptions.data.[0].status'); 43 | return status && status === 'active' ? true : false; 44 | }; 45 | -------------------------------------------------------------------------------- /server/static/countries.json: -------------------------------------------------------------------------------- 1 | { 2 | "AF": { 3 | "countryName": "Afghanistan", 4 | "countryPrefix": "93" 5 | }, 6 | "AL": { 7 | "countryName": "Albania", 8 | "countryPrefix": "355" 9 | }, 10 | "DZ": { 11 | "countryName": "Algeria", 12 | "countryPrefix": "213" 13 | }, 14 | "AS": { 15 | "countryName": "American Samoa", 16 | "countryPrefix": "1" 17 | }, 18 | "AD": { 19 | "countryName": "Andorra", 20 | "countryPrefix": "376" 21 | }, 22 | "AO": { 23 | "countryName": "Angola", 24 | "countryPrefix": "244" 25 | }, 26 | "AI": { 27 | "countryName": "Anguilla", 28 | "countryPrefix": "1" 29 | }, 30 | "AG": { 31 | "countryName": "Antigua", 32 | "countryPrefix": "1" 33 | }, 34 | "AR": { 35 | "countryName": "Argentina", 36 | "countryPrefix": "54" 37 | }, 38 | "AM": { 39 | "countryName": "Armenia", 40 | "countryPrefix": "374" 41 | }, 42 | "AW": { 43 | "countryName": "Aruba", 44 | "countryPrefix": "297" 45 | }, 46 | "AU": { 47 | "countryName": "Australia", 48 | "countryPrefix": "61" 49 | }, 50 | "AT": { 51 | "countryName": "Austria", 52 | "countryPrefix": "43" 53 | }, 54 | "AZ": { 55 | "countryName": "Azerbaijan", 56 | "countryPrefix": "994" 57 | }, 58 | "BH": { 59 | "countryName": "Bahrain", 60 | "countryPrefix": "973" 61 | }, 62 | "BD": { 63 | "countryName": "Bangladesh", 64 | "countryPrefix": "880" 65 | }, 66 | "BB": { 67 | "countryName": "Barbados", 68 | "countryPrefix": "1" 69 | }, 70 | "BY": { 71 | "countryName": "Belarus", 72 | "countryPrefix": "375" 73 | }, 74 | "BE": { 75 | "countryName": "Belgium", 76 | "countryPrefix": "32" 77 | }, 78 | "BZ": { 79 | "countryName": "Belize", 80 | "countryPrefix": "501" 81 | }, 82 | "BJ": { 83 | "countryName": "Benin", 84 | "countryPrefix": "229" 85 | }, 86 | "BM": { 87 | "countryName": "Bermuda", 88 | "countryPrefix": "1" 89 | }, 90 | "BT": { 91 | "countryName": "Bhutan", 92 | "countryPrefix": "975" 93 | }, 94 | "BO": { 95 | "countryName": "Bolivia", 96 | "countryPrefix": "591" 97 | }, 98 | "BA": { 99 | "countryName": "Bosnia and Herzegovina", 100 | "countryPrefix": "387" 101 | }, 102 | "BW": { 103 | "countryName": "Botswana", 104 | "countryPrefix": "267" 105 | }, 106 | "BR": { 107 | "countryName": "Brazil", 108 | "countryPrefix": "55" 109 | }, 110 | "IO": { 111 | "countryName": "British Indian Ocean Territory", 112 | "countryPrefix": "246" 113 | }, 114 | "VG": { 115 | "countryName": "British Virgin Islands", 116 | "countryPrefix": "1" 117 | }, 118 | "BN": { 119 | "countryName": "Brunei", 120 | "countryPrefix": "673" 121 | }, 122 | "BG": { 123 | "countryName": "Bulgaria", 124 | "countryPrefix": "359" 125 | }, 126 | "BF": { 127 | "countryName": "Burkina Faso", 128 | "countryPrefix": "226" 129 | }, 130 | "MM": { 131 | "countryName": "Burma Myanmar", 132 | "countryPrefix": "95" 133 | }, 134 | "BI": { 135 | "countryName": "Burundi", 136 | "countryPrefix": "257" 137 | }, 138 | "KH": { 139 | "countryName": "Cambodia", 140 | "countryPrefix": "855" 141 | }, 142 | "CM": { 143 | "countryName": "Cameroon", 144 | "countryPrefix": "237" 145 | }, 146 | "CA": { 147 | "countryName": "Canada", 148 | "countryPrefix": "1" 149 | }, 150 | "CV": { 151 | "countryName": "Cape Verde", 152 | "countryPrefix": "238" 153 | }, 154 | "KY": { 155 | "countryName": "Cayman Islands", 156 | "countryPrefix": "1" 157 | }, 158 | "CF": { 159 | "countryName": "Central African Republic", 160 | "countryPrefix": "236" 161 | }, 162 | "TD": { 163 | "countryName": "Chad", 164 | "countryPrefix": "235" 165 | }, 166 | "CL": { 167 | "countryName": "Chile", 168 | "countryPrefix": "56" 169 | }, 170 | "CN": { 171 | "countryName": "China", 172 | "countryPrefix": "86" 173 | }, 174 | "CO": { 175 | "countryName": "Colombia", 176 | "countryPrefix": "57" 177 | }, 178 | "KM": { 179 | "countryName": "Comoros", 180 | "countryPrefix": "269" 181 | }, 182 | "CK": { 183 | "countryName": "Cook Islands", 184 | "countryPrefix": "682" 185 | }, 186 | "CR": { 187 | "countryName": "Costa Rica", 188 | "countryPrefix": "506" 189 | }, 190 | "CI": { 191 | "countryName": "C\u00f4te d'Ivoire", 192 | "countryPrefix": "225" 193 | }, 194 | "HR": { 195 | "countryName": "Croatia", 196 | "countryPrefix": "385" 197 | }, 198 | "CU": { 199 | "countryName": "Cuba", 200 | "countryPrefix": "53" 201 | }, 202 | "CY": { 203 | "countryName": "Cyprus", 204 | "countryPrefix": "357" 205 | }, 206 | "CZ": { 207 | "countryName": "Czech Republic", 208 | "countryPrefix": "420" 209 | }, 210 | "CD": { 211 | "countryName": "Democratic Republic of Congo", 212 | "countryPrefix": "243" 213 | }, 214 | "DK": { 215 | "countryName": "Denmark", 216 | "countryPrefix": "45" 217 | }, 218 | "DJ": { 219 | "countryName": "Djibouti", 220 | "countryPrefix": "253" 221 | }, 222 | "DM": { 223 | "countryName": "Dominica", 224 | "countryPrefix": "1" 225 | }, 226 | "DO": { 227 | "countryName": "Dominican Republic", 228 | "countryPrefix": "1" 229 | }, 230 | "EC": { 231 | "countryName": "Ecuador", 232 | "countryPrefix": "593" 233 | }, 234 | "EG": { 235 | "countryName": "Egypt", 236 | "countryPrefix": "20" 237 | }, 238 | "SV": { 239 | "countryName": "El Salvador", 240 | "countryPrefix": "503" 241 | }, 242 | "GQ": { 243 | "countryName": "Equatorial Guinea", 244 | "countryPrefix": "240" 245 | }, 246 | "ER": { 247 | "countryName": "Eritrea", 248 | "countryPrefix": "291" 249 | }, 250 | "EE": { 251 | "countryName": "Estonia", 252 | "countryPrefix": "372" 253 | }, 254 | "ET": { 255 | "countryName": "Ethiopia", 256 | "countryPrefix": "251" 257 | }, 258 | "FK": { 259 | "countryName": "Falkland Islands", 260 | "countryPrefix": "500" 261 | }, 262 | "FO": { 263 | "countryName": "Faroe Islands", 264 | "countryPrefix": "298" 265 | }, 266 | "FM": { 267 | "countryName": "Federated States of Micronesia", 268 | "countryPrefix": "691" 269 | }, 270 | "FJ": { 271 | "countryName": "Fiji", 272 | "countryPrefix": "679" 273 | }, 274 | "FI": { 275 | "countryName": "Finland", 276 | "countryPrefix": "358" 277 | }, 278 | "FR": { 279 | "countryName": "France", 280 | "countryPrefix": "33" 281 | }, 282 | "GF": { 283 | "countryName": "French Guiana", 284 | "countryPrefix": "594" 285 | }, 286 | "PF": { 287 | "countryName": "French Polynesia", 288 | "countryPrefix": "689" 289 | }, 290 | "GA": { 291 | "countryName": "Gabon", 292 | "countryPrefix": "241" 293 | }, 294 | "GE": { 295 | "countryName": "Georgia", 296 | "countryPrefix": "995" 297 | }, 298 | "DE": { 299 | "countryName": "Germany", 300 | "countryPrefix": "49" 301 | }, 302 | "GH": { 303 | "countryName": "Ghana", 304 | "countryPrefix": "233" 305 | }, 306 | "GI": { 307 | "countryName": "Gibraltar", 308 | "countryPrefix": "350" 309 | }, 310 | "GR": { 311 | "countryName": "Greece", 312 | "countryPrefix": "30" 313 | }, 314 | "GL": { 315 | "countryName": "Greenland", 316 | "countryPrefix": "299" 317 | }, 318 | "GD": { 319 | "countryName": "Grenada", 320 | "countryPrefix": "1" 321 | }, 322 | "GP": { 323 | "countryName": "Guadeloupe", 324 | "countryPrefix": "590" 325 | }, 326 | "GU": { 327 | "countryName": "Guam", 328 | "countryPrefix": "1" 329 | }, 330 | "GT": { 331 | "countryName": "Guatemala", 332 | "countryPrefix": "502" 333 | }, 334 | "GN": { 335 | "countryName": "Guinea", 336 | "countryPrefix": "224" 337 | }, 338 | "GW": { 339 | "countryName": "Guinea-Bissau", 340 | "countryPrefix": "245" 341 | }, 342 | "GY": { 343 | "countryName": "Guyana", 344 | "countryPrefix": "592" 345 | }, 346 | "HT": { 347 | "countryName": "Haiti", 348 | "countryPrefix": "509" 349 | }, 350 | "HN": { 351 | "countryName": "Honduras", 352 | "countryPrefix": "504" 353 | }, 354 | "HK": { 355 | "countryName": "Hong Kong", 356 | "countryPrefix": "852" 357 | }, 358 | "HU": { 359 | "countryName": "Hungary", 360 | "countryPrefix": "36" 361 | }, 362 | "IS": { 363 | "countryName": "Iceland", 364 | "countryPrefix": "354" 365 | }, 366 | "IN": { 367 | "countryName": "India", 368 | "countryPrefix": "91" 369 | }, 370 | "ID": { 371 | "countryName": "Indonesia", 372 | "countryPrefix": "62" 373 | }, 374 | "IR": { 375 | "countryName": "Iran", 376 | "countryPrefix": "98" 377 | }, 378 | "IQ": { 379 | "countryName": "Iraq", 380 | "countryPrefix": "964" 381 | }, 382 | "IE": { 383 | "countryName": "Ireland", 384 | "countryPrefix": "353" 385 | }, 386 | "IL": { 387 | "countryName": "Israel", 388 | "countryPrefix": "972" 389 | }, 390 | "IT": { 391 | "countryName": "Italy", 392 | "countryPrefix": "39" 393 | }, 394 | "JM": { 395 | "countryName": "Jamaica", 396 | "countryPrefix": "1" 397 | }, 398 | "JP": { 399 | "countryName": "Japan", 400 | "countryPrefix": "81" 401 | }, 402 | "JO": { 403 | "countryName": "Jordan", 404 | "countryPrefix": "962" 405 | }, 406 | "KZ": { 407 | "countryName": "Kazakhstan", 408 | "countryPrefix": "7" 409 | }, 410 | "KE": { 411 | "countryName": "Kenya", 412 | "countryPrefix": "254" 413 | }, 414 | "KI": { 415 | "countryName": "Kiribati", 416 | "countryPrefix": "686" 417 | }, 418 | "XK": { 419 | "countryName": "Kosovo", 420 | "countryPrefix": "381" 421 | }, 422 | "KW": { 423 | "countryName": "Kuwait", 424 | "countryPrefix": "965" 425 | }, 426 | "KG": { 427 | "countryName": "Kyrgyzstan", 428 | "countryPrefix": "996" 429 | }, 430 | "LA": { 431 | "countryName": "Laos", 432 | "countryPrefix": "856" 433 | }, 434 | "LV": { 435 | "countryName": "Latvia", 436 | "countryPrefix": "371" 437 | }, 438 | "LB": { 439 | "countryName": "Lebanon", 440 | "countryPrefix": "961" 441 | }, 442 | "LS": { 443 | "countryName": "Lesotho", 444 | "countryPrefix": "266" 445 | }, 446 | "LR": { 447 | "countryName": "Liberia", 448 | "countryPrefix": "231" 449 | }, 450 | "LY": { 451 | "countryName": "Libya", 452 | "countryPrefix": "218" 453 | }, 454 | "LI": { 455 | "countryName": "Liechtenstein", 456 | "countryPrefix": "423" 457 | }, 458 | "LT": { 459 | "countryName": "Lithuania", 460 | "countryPrefix": "370" 461 | }, 462 | "LU": { 463 | "countryName": "Luxembourg", 464 | "countryPrefix": "352" 465 | }, 466 | "MO": { 467 | "countryName": "Macau", 468 | "countryPrefix": "853" 469 | }, 470 | "MK": { 471 | "countryName": "Macedonia", 472 | "countryPrefix": "389" 473 | }, 474 | "MG": { 475 | "countryName": "Madagascar", 476 | "countryPrefix": "261" 477 | }, 478 | "MW": { 479 | "countryName": "Malawi", 480 | "countryPrefix": "265" 481 | }, 482 | "MY": { 483 | "countryName": "Malaysia", 484 | "countryPrefix": "60" 485 | }, 486 | "MV": { 487 | "countryName": "Maldives", 488 | "countryPrefix": "960" 489 | }, 490 | "ML": { 491 | "countryName": "Mali", 492 | "countryPrefix": "223" 493 | }, 494 | "MT": { 495 | "countryName": "Malta", 496 | "countryPrefix": "356" 497 | }, 498 | "MH": { 499 | "countryName": "Marshall Islands", 500 | "countryPrefix": "692" 501 | }, 502 | "MQ": { 503 | "countryName": "Martinique", 504 | "countryPrefix": "596" 505 | }, 506 | "MR": { 507 | "countryName": "Mauritania", 508 | "countryPrefix": "222" 509 | }, 510 | "MU": { 511 | "countryName": "Mauritius", 512 | "countryPrefix": "230" 513 | }, 514 | "YT": { 515 | "countryName": "Mayotte", 516 | "countryPrefix": "262" 517 | }, 518 | "MX": { 519 | "countryName": "Mexico", 520 | "countryPrefix": "52" 521 | }, 522 | "MD": { 523 | "countryName": "Moldova", 524 | "countryPrefix": "373" 525 | }, 526 | "MC": { 527 | "countryName": "Monaco", 528 | "countryPrefix": "377" 529 | }, 530 | "MN": { 531 | "countryName": "Mongolia", 532 | "countryPrefix": "976" 533 | }, 534 | "ME": { 535 | "countryName": "Montenegro", 536 | "countryPrefix": "382" 537 | }, 538 | "MS": { 539 | "countryName": "Montserrat", 540 | "countryPrefix": "1" 541 | }, 542 | "MA": { 543 | "countryName": "Morocco", 544 | "countryPrefix": "212" 545 | }, 546 | "MZ": { 547 | "countryName": "Mozambique", 548 | "countryPrefix": "258" 549 | }, 550 | "NA": { 551 | "countryName": "Namibia", 552 | "countryPrefix": "264" 553 | }, 554 | "NR": { 555 | "countryName": "Nauru", 556 | "countryPrefix": "674" 557 | }, 558 | "NP": { 559 | "countryName": "Nepal", 560 | "countryPrefix": "977" 561 | }, 562 | "NL": { 563 | "countryName": "Netherlands", 564 | "countryPrefix": "31" 565 | }, 566 | "AN": { 567 | "countryName": "Netherlands Antilles", 568 | "countryPrefix": "599" 569 | }, 570 | "NC": { 571 | "countryName": "New Caledonia", 572 | "countryPrefix": "687" 573 | }, 574 | "NZ": { 575 | "countryName": "New Zealand", 576 | "countryPrefix": "64" 577 | }, 578 | "NI": { 579 | "countryName": "Nicaragua", 580 | "countryPrefix": "505" 581 | }, 582 | "NE": { 583 | "countryName": "Niger", 584 | "countryPrefix": "227" 585 | }, 586 | "NG": { 587 | "countryName": "Nigeria", 588 | "countryPrefix": "234" 589 | }, 590 | "NU": { 591 | "countryName": "Niue", 592 | "countryPrefix": "683" 593 | }, 594 | "NF": { 595 | "countryName": "Norfolk Island", 596 | "countryPrefix": "672" 597 | }, 598 | "KP": { 599 | "countryName": "North Korea", 600 | "countryPrefix": "850" 601 | }, 602 | "MP": { 603 | "countryName": "Northern Mariana Islands", 604 | "countryPrefix": "1" 605 | }, 606 | "NO": { 607 | "countryName": "Norway", 608 | "countryPrefix": "47" 609 | }, 610 | "OM": { 611 | "countryName": "Oman", 612 | "countryPrefix": "968" 613 | }, 614 | "PK": { 615 | "countryName": "Pakistan", 616 | "countryPrefix": "92" 617 | }, 618 | "PW": { 619 | "countryName": "Palau", 620 | "countryPrefix": "680" 621 | }, 622 | "PS": { 623 | "countryName": "Palestine", 624 | "countryPrefix": "970" 625 | }, 626 | "PA": { 627 | "countryName": "Panama", 628 | "countryPrefix": "507" 629 | }, 630 | "PG": { 631 | "countryName": "Papua New Guinea", 632 | "countryPrefix": "675" 633 | }, 634 | "PY": { 635 | "countryName": "Paraguay", 636 | "countryPrefix": "595" 637 | }, 638 | "PE": { 639 | "countryName": "Peru", 640 | "countryPrefix": "51" 641 | }, 642 | "PH": { 643 | "countryName": "Philippines", 644 | "countryPrefix": "63" 645 | }, 646 | "PL": { 647 | "countryName": "Poland", 648 | "countryPrefix": "48" 649 | }, 650 | "PT": { 651 | "countryName": "Portugal", 652 | "countryPrefix": "351" 653 | }, 654 | "PR": { 655 | "countryName": "Puerto Rico", 656 | "countryPrefix": "1" 657 | }, 658 | "QA": { 659 | "countryName": "Qatar", 660 | "countryPrefix": "974" 661 | }, 662 | "CG": { 663 | "countryName": "Republic of the Congo", 664 | "countryPrefix": "242" 665 | }, 666 | "RE": { 667 | "countryName": "R\u00e9union", 668 | "countryPrefix": "262" 669 | }, 670 | "RO": { 671 | "countryName": "Romania", 672 | "countryPrefix": "40" 673 | }, 674 | "RU": { 675 | "countryName": "Russia", 676 | "countryPrefix": "7" 677 | }, 678 | "RW": { 679 | "countryName": "Rwanda", 680 | "countryPrefix": "250" 681 | }, 682 | "BL": { 683 | "countryName": "Saint Barth\u00e9lemy", 684 | "countryPrefix": "590" 685 | }, 686 | "SH": { 687 | "countryName": "Saint Helena", 688 | "countryPrefix": "290" 689 | }, 690 | "KN": { 691 | "countryName": "Saint Kitts and Nevis", 692 | "countryPrefix": "1" 693 | }, 694 | "MF": { 695 | "countryName": "Saint Martin", 696 | "countryPrefix": "590" 697 | }, 698 | "PM": { 699 | "countryName": "Saint Pierre and Miquelon", 700 | "countryPrefix": "508" 701 | }, 702 | "VC": { 703 | "countryName": "Saint Vincent and the Grenadines", 704 | "countryPrefix": "1" 705 | }, 706 | "WS": { 707 | "countryName": "Samoa", 708 | "countryPrefix": "685" 709 | }, 710 | "SM": { 711 | "countryName": "San Marino", 712 | "countryPrefix": "378" 713 | }, 714 | "ST": { 715 | "countryName": "S\u00e3o Tom\u00e9 and Pr\u00edncipe", 716 | "countryPrefix": "239" 717 | }, 718 | "SA": { 719 | "countryName": "Saudi Arabia", 720 | "countryPrefix": "966" 721 | }, 722 | "SN": { 723 | "countryName": "Senegal", 724 | "countryPrefix": "221" 725 | }, 726 | "RS": { 727 | "countryName": "Serbia", 728 | "countryPrefix": "381" 729 | }, 730 | "SC": { 731 | "countryName": "Seychelles", 732 | "countryPrefix": "248" 733 | }, 734 | "SL": { 735 | "countryName": "Sierra Leone", 736 | "countryPrefix": "232" 737 | }, 738 | "SG": { 739 | "countryName": "Singapore", 740 | "countryPrefix": "65" 741 | }, 742 | "SK": { 743 | "countryName": "Slovakia", 744 | "countryPrefix": "421" 745 | }, 746 | "SI": { 747 | "countryName": "Slovenia", 748 | "countryPrefix": "386" 749 | }, 750 | "SB": { 751 | "countryName": "Solomon Islands", 752 | "countryPrefix": "677" 753 | }, 754 | "SO": { 755 | "countryName": "Somalia", 756 | "countryPrefix": "252" 757 | }, 758 | "ZA": { 759 | "countryName": "South Africa", 760 | "countryPrefix": "27" 761 | }, 762 | "KR": { 763 | "countryName": "South Korea", 764 | "countryPrefix": "82" 765 | }, 766 | "ES": { 767 | "countryName": "Spain", 768 | "countryPrefix": "34" 769 | }, 770 | "LK": { 771 | "countryName": "Sri Lanka", 772 | "countryPrefix": "94" 773 | }, 774 | "LC": { 775 | "countryName": "St. Lucia", 776 | "countryPrefix": "1" 777 | }, 778 | "SD": { 779 | "countryName": "Sudan", 780 | "countryPrefix": "249" 781 | }, 782 | "SR": { 783 | "countryName": "Suriname", 784 | "countryPrefix": "597" 785 | }, 786 | "SZ": { 787 | "countryName": "Swaziland", 788 | "countryPrefix": "268" 789 | }, 790 | "SE": { 791 | "countryName": "Sweden", 792 | "countryPrefix": "46" 793 | }, 794 | "CH": { 795 | "countryName": "Switzerland", 796 | "countryPrefix": "41" 797 | }, 798 | "SY": { 799 | "countryName": "Syria", 800 | "countryPrefix": "963" 801 | }, 802 | "TW": { 803 | "countryName": "Taiwan", 804 | "countryPrefix": "886" 805 | }, 806 | "TJ": { 807 | "countryName": "Tajikistan", 808 | "countryPrefix": "992" 809 | }, 810 | "TZ": { 811 | "countryName": "Tanzania", 812 | "countryPrefix": "255" 813 | }, 814 | "TH": { 815 | "countryName": "Thailand", 816 | "countryPrefix": "66" 817 | }, 818 | "BS": { 819 | "countryName": "The Bahamas", 820 | "countryPrefix": "1" 821 | }, 822 | "GM": { 823 | "countryName": "The Gambia", 824 | "countryPrefix": "220" 825 | }, 826 | "TL": { 827 | "countryName": "Timor-Leste", 828 | "countryPrefix": "670" 829 | }, 830 | "TG": { 831 | "countryName": "Togo", 832 | "countryPrefix": "228" 833 | }, 834 | "TK": { 835 | "countryName": "Tokelau", 836 | "countryPrefix": "690" 837 | }, 838 | "TO": { 839 | "countryName": "Tonga", 840 | "countryPrefix": "676" 841 | }, 842 | "TT": { 843 | "countryName": "Trinidad and Tobago", 844 | "countryPrefix": "1" 845 | }, 846 | "TN": { 847 | "countryName": "Tunisia", 848 | "countryPrefix": "216" 849 | }, 850 | "TR": { 851 | "countryName": "Turkey", 852 | "countryPrefix": "90" 853 | }, 854 | "TM": { 855 | "countryName": "Turkmenistan", 856 | "countryPrefix": "993" 857 | }, 858 | "TC": { 859 | "countryName": "Turks and Caicos Islands", 860 | "countryPrefix": "1" 861 | }, 862 | "TV": { 863 | "countryName": "Tuvalu", 864 | "countryPrefix": "688" 865 | }, 866 | "UG": { 867 | "countryName": "Uganda", 868 | "countryPrefix": "256" 869 | }, 870 | "UA": { 871 | "countryName": "Ukraine", 872 | "countryPrefix": "380" 873 | }, 874 | "AE": { 875 | "countryName": "United Arab Emirates", 876 | "countryPrefix": "971" 877 | }, 878 | "GB": { 879 | "countryName": "United Kingdom", 880 | "countryPrefix": "44" 881 | }, 882 | "US": { 883 | "countryName": "United States", 884 | "countryPrefix": "1" 885 | }, 886 | "UY": { 887 | "countryName": "Uruguay", 888 | "countryPrefix": "598" 889 | }, 890 | "VI": { 891 | "countryName": "US Virgin Islands", 892 | "countryPrefix": "1" 893 | }, 894 | "UZ": { 895 | "countryName": "Uzbekistan", 896 | "countryPrefix": "998" 897 | }, 898 | "VU": { 899 | "countryName": "Vanuatu", 900 | "countryPrefix": "678" 901 | }, 902 | "VA": { 903 | "countryName": "Vatican City", 904 | "countryPrefix": "39" 905 | }, 906 | "VE": { 907 | "countryName": "Venezuela", 908 | "countryPrefix": "58" 909 | }, 910 | "VN": { 911 | "countryName": "Vietnam", 912 | "countryPrefix": "84" 913 | }, 914 | "WF": { 915 | "countryName": "Wallis and Futuna", 916 | "countryPrefix": "681" 917 | }, 918 | "YE": { 919 | "countryName": "Yemen", 920 | "countryPrefix": "967" 921 | }, 922 | "ZM": { 923 | "countryName": "Zambia", 924 | "countryPrefix": "260" 925 | }, 926 | "ZW": { 927 | "countryName": "Zimbabwe", 928 | "countryPrefix": "263" 929 | } 930 | } -------------------------------------------------------------------------------- /server/utils/common.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const crypto = require('crypto'); 3 | const jsonwebtoken = require('jsonwebtoken'); 4 | 5 | /** 6 | * Parses a string to an integer. 7 | * Useful for converting environment variable (while maintaing the 0 values). 8 | * @param {Number|String} input The string to convert to integer. 9 | * @param {Number} defaultOutput Returned if the string is not a valid number. 10 | * @return {Promise} The generated Promise. 11 | */ 12 | exports.toInt = (input, defaultOutput) => { 13 | if (typeof input === 'number') { 14 | return input; 15 | } 16 | if (input !== undefined && input !== null && !isNaN(input)) { 17 | return Number.parseInt(input, 10); 18 | } else { 19 | return defaultOutput; 20 | } 21 | }; 22 | 23 | /** 24 | * Helper function to convert a callback to a Promise. 25 | * @param {Function} fn The function to promisify. 26 | * @return {Promise} The generated Promise. 27 | */ 28 | exports.promisify = async fn => { 29 | return await new Promise((resolve, reject) => { 30 | const callback = (err, res) => { 31 | if (err) { 32 | return reject(err); 33 | } 34 | return resolve(res); 35 | }; 36 | fn(callback); 37 | }); 38 | }; 39 | 40 | /** 41 | * Get the first key of the object that matches the given key using a 42 | * case-insensitive search. 43 | * @param {Object} obj The object to search into. 44 | * @param {String} searchedKey The searched key (case does not matter). 45 | * @return {Any} The found key (or undefined). 46 | */ 47 | exports.getObjectKey = (obj, searchedKey) => { 48 | const foundKey = _.findKey(obj, (value, key) => { 49 | return key.toLowerCase() === searchedKey; 50 | }); 51 | return foundKey; 52 | }; 53 | 54 | /** 55 | * Gets a value from an object given its key using a case-insensitive search. 56 | * @param {Object} obj The object to search into. 57 | * @param {String} searchedKey The searched key (case does not matter). 58 | * @return {Any} The found value (or undefined). 59 | */ 60 | exports.getObjectValueByKey = (obj, searchedKey) => { 61 | const foundKey = exports.getObjectKey(obj, searchedKey); 62 | return foundKey ? obj[foundKey] : undefined; 63 | }; 64 | 65 | /** 66 | * Checks if a JWT token is expired. 67 | * @param {Object} token The JWT token. 68 | * @return {Boolean} True if the JWT token is expired. 69 | */ 70 | exports.isJwtTokenExpired = token => { 71 | const payload = jsonwebtoken.decode(token); 72 | const clockTimestamp = Math.floor(Date.now() / 1000); 73 | return clockTimestamp >= payload.exp; 74 | }; 75 | 76 | /** 77 | * Generates an API token of 32 characters composed by 16 random bytes. 78 | * @return {String} The generated API token. 79 | */ 80 | exports.generateApiToken = () => { 81 | const randomBytes = crypto.randomBytes(16).toString('hex'); 82 | return randomBytes; 83 | }; 84 | 85 | /** 86 | * Checks if an API token is valid. 87 | * @param {String} apiToken The API token. 88 | * @return {Boolean} True if the API token is valid. 89 | */ 90 | exports.isApiTokenValid = apiToken => { 91 | if (!_.isString(apiToken)) { 92 | return false; 93 | } 94 | if (apiToken.length !== 32) { 95 | return false; 96 | } 97 | const isHexRegexp = new RegExp('^[0-9a-fA-F]{32}$'); 98 | if (!isHexRegexp.test(apiToken)) { 99 | return false; 100 | } 101 | return true; 102 | }; 103 | -------------------------------------------------------------------------------- /server/utils/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const keys = require('../config/keys'); 3 | require('winston-papertrail').Papertrail; 4 | 5 | const papertrailEnabled = keys.IS_ENV_PRODUCTION && keys.PAPERTRAIL_HOST && keys.PAPERTRAIL_PORT; 6 | 7 | const transports = []; 8 | 9 | transports.push( 10 | new winston.transports.Console({ 11 | colorize: true, 12 | prettyPrint: true, 13 | }) 14 | ); 15 | 16 | if (papertrailEnabled) { 17 | transports.push( 18 | new winston.transports.Papertrail({ 19 | host: keys.PAPERTRAIL_HOST, 20 | port: keys.PAPERTRAIL_PORT, 21 | colorize: true, 22 | }) 23 | ); 24 | } 25 | 26 | const logger = new winston.Logger({ 27 | transports: transports, 28 | }); 29 | 30 | module.exports = logger; 31 | --------------------------------------------------------------------------------