├── .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 | [](https://github.com/ellerbrock/open-source-badges/)
7 | [](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 |
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 |
25 |
26 | {children}
27 |
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 = {rightButtonText} ;
33 | }
34 | return (
35 |
36 |
37 |
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 |
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 |
this._showModal('FREE')}
94 | disabled={this.state.updatingSubscriptionPlan}
95 | type={'info'}
96 | >
97 | {strings.SUBSCRIPTION_SELECT_BUTTON}
98 |
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 |
this._showModal('PRO')}
121 | disabled={this.state.updatingSubscriptionPlan}
122 | type={'info'}
123 | >
124 | {strings.SUBSCRIPTION_SELECT_BUTTON}
125 |
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 |
26 | {strings.CANCEL}
27 |
28 |
29 |
30 | {amount &&
31 | onStripeTokenReceived && (
32 |
38 | )}
39 | {!amount && (
40 |
41 | {strings.CONFIRM}
42 |
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 | this._showToken(apiToken.value)}
135 | type={"info"}
136 | >
137 | {strings.API_TOKEN_REVEAL_BUTTON}
138 |
139 |
140 | )}
141 |
142 |
143 | {format(apiToken.createdAt, "MMM D, YYYY")}
144 |
145 |
146 |
151 | !disableDelete && this._deleteToken(apiToken.value)}
152 | >
153 |
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 |
49 | {strings.API_TOKEN_GENERATOR_BUTTON_TEXT}
50 |
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 |
25 |
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 |
22 | {strings.CLOSE}
23 |
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 | {text}
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 |
169 | {strings.DASHBOARD_ERROR_BUTTON}
170 |
171 |
172 |
173 | );
174 | } else {
175 | return (
176 |
177 |
178 |
179 |
182 |
183 |
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 |
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 |
85 | {strings.HOME_SIGNUP_NOW_AUTHENTICATED}
86 |
87 | ) : (
88 |
89 | {strings.HOME_SIGNUP_NOW_UNAUTHENTICATED}
90 |
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 |
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 |
--------------------------------------------------------------------------------