├── .gitignore ├── README.md ├── client ├── .env ├── .env-sample ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.js │ ├── App.scss │ ├── App.test.js │ ├── HomePage.js │ ├── account │ ├── AccountActivation.js │ ├── Login.js │ ├── Logout.js │ ├── Register.js │ ├── ResendActivationLink.js │ ├── ResetPassword.js │ ├── ResetPasswordLink.js │ ├── account.scss │ ├── actionsAuth.js │ └── reducerAuth.js │ ├── common │ └── Errors.js │ ├── header │ └── Header.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── payments │ ├── CreateSubscription.js │ ├── CreditCardForm.js │ ├── CurrentCard.js │ ├── CurrentSubscription.js │ ├── Payments.js │ ├── StripeProviderComponent.js │ └── UpdateCard.js │ ├── serviceWorker.js │ └── store │ ├── configureStore.js │ └── rootReducer.js ├── client2 ├── .env ├── .env-sample ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.js │ ├── App.scss │ ├── App.test.js │ ├── HomePage.js │ ├── account │ ├── AccountActivation.js │ ├── Login.js │ ├── Logout.js │ ├── Register.js │ ├── ResendActivationLink.js │ ├── ResetPassword.js │ ├── ResetPasswordLink.js │ ├── account.scss │ ├── actionsAuth.js │ └── reducerAuth.js │ ├── common │ └── Errors.js │ ├── header │ └── Header.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── payments │ ├── CreateSubscription.js │ ├── CreditCardForm.js │ ├── CurrentCard.js │ ├── CurrentSubscription.js │ ├── Payments.js │ ├── StripeProviderComponent.js │ └── UpdateCard.js │ ├── serviceWorker.js │ └── store │ ├── configureStore.js │ └── rootReducer.js ├── package.json ├── server ├── .env-sample ├── controllers │ ├── authController.js │ └── paymentsController.js ├── db │ └── databaseSetup.js ├── lib │ ├── EmailManager.js │ ├── StripeManager.js │ └── passport.js ├── models │ └── User.js ├── package-lock.json ├── package.json ├── routes.js └── server.js └── xscreenshots ├── 1.HomePage.png ├── 2.Register.png ├── 3.Login.png ├── 4.ResendActivationLink.png ├── 5.ResetPasswordLink.png ├── 6.ResetPassword.png └── 7.Payments.png /.gitignore: -------------------------------------------------------------------------------- 1 | server/node_modules/ 2 | node_modules/ 3 | server/.env 4 | 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SAASiFy 2 | 3 | SAASiFy is a sample SAAS app built with NodeJS and ReactJS. It is primarily built for the Udemy course [SaasiFy - Build a complete SAAS App this weekend](https://www.udemy.com/course/2786970). 4 | 5 | The commits are ordered in the same order as the videos in the course. 6 | 7 | 8 | ## Getting Started 9 | 10 | 1. Fork or clone the repository 11 | 2. You will get 2 folders -> `client` and `server`. 12 | 3. Run `npm install` from each of these folders. 13 | 4. Copy the provided `server/.env.sample` file and create a new file `server/.env`. Populate this with your values. 14 | 4. Start `client` with `npm start` inside `client` folder. 15 | 5. Start `server` with `npm start` inside `server` folder. 16 | 17 | 18 | 19 | ## Demo of all the APIs using POSTMAN 20 | The collection is available at [https://www.getpostman.com/collections/7acbd32bce8a734cdb7d](https://www.getpostman.com/collections/7acbd32bce8a734cdb7d) 21 | 22 | [![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/w22HbHNxhv0/0.jpg)](https://www.youtube.com/watch?v=w22HbHNxhv0) 23 | 24 | ## Features/Screenshots 25 | 26 | ### Home Page 27 | 28 | ![1. Home Page](xscreenshots/1.HomePage.png) 29 | 30 | ### Registration System 31 | 32 | 1. User Registers for the App 33 | 2. Send email for email address verification 34 | 3. Resend email when users don't receive it 35 | 4. Activate Account when email link is clicked 36 | 37 | ![2. Register](xscreenshots/2.Register.png) 38 | 39 | ### Authentication System 40 | 41 | 1. User login 42 | 2. JWT token based auth 43 | 44 | ![3. Login](xscreenshots/3.Login.png) 45 | 46 | ### Password System 47 | 48 | 1. Send email for Forgot Password 49 | 2. Allow password reset after clicking on email link 50 | 51 | ![4. Resend Activation Link](xscreenshots/4.ResendActivationLink.png) 52 | ![5. Reset Password Link](xscreenshots/5.ResetPasswordLink.png) 53 | ![6. Reset Password](xscreenshots/6.ResetPassword.png) 54 | 55 | ### Payments 56 | 57 | 1. Integrate with Stripe 58 | 2. Subscriptions - Subscribe to new plan 59 | 3. Subscriptions - Upgrade or Downgrade existing plan 60 | 4. Subscriptions - Unsubscribe(Delete) from all plans 61 | 5. Credit Cards - Save card to account 62 | 6. Credit Cards - Update saved card to account 63 | 64 | ![7. Payments](xscreenshots/7.Payments.png) 65 | 66 | 67 | ### Email Integration 68 | 69 | 1. Mailgun API Integration 70 | 2. Learn to create, edit and send HTML template based emails 71 | 3. Use knowledge to send any email 72 | 73 | ### Postman APIs 74 | 75 | 1. Learn to test APIs using POSTMAN 76 | 2. Run whole application without opening browser 77 | 3. API collection provided 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | . -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_cPAYuvk1DKJ0cqm0cm0Zblms 2 | -------------------------------------------------------------------------------- /client/.env-sample: -------------------------------------------------------------------------------- 1 | REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE 2 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "bootstrap": "^4.4.0", 7 | "js-cookie": "^2.2.1", 8 | "lodash": "^4.17.15", 9 | "node-sass": "^4.13.0", 10 | "query-string": "^6.9.0", 11 | "react": "^16.12.0", 12 | "react-bootstrap": "^1.0.0-beta.16", 13 | "react-dom": "^16.12.0", 14 | "react-notifications": "^1.4.3", 15 | "react-redux": "^7.2.0", 16 | "react-router-dom": "^5.1.2", 17 | "react-scripts": "3.2.0", 18 | "react-stripe-elements": "^6.0.1", 19 | "redux": "^4.0.5", 20 | "redux-logger": "^3.0.6", 21 | "redux-thunk": "^2.3.0" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "proxy": "http://localhost:3001", 45 | "engines": { 46 | "node": ">=10.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 28 | 29 | 30 | 31 | 32 | SaasiFy 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route 4 | } from 'react-router-dom'; 5 | import HomePage from './HomePage'; 6 | import Register from './account/Register'; 7 | import AccountActivation from './account/AccountActivation'; 8 | import ResendActivationLink from './account/ResendActivationLink'; 9 | import ResetPasswordLink from './account/ResetPasswordLink'; 10 | import ResetPassword from './account/ResetPassword'; 11 | import Login from './account/Login'; 12 | import Logout from './account/Logout'; 13 | import Header from './header/Header' 14 | import Payments from './payments/Payments'; 15 | import { NotificationContainer } from 'react-notifications'; 16 | import { connect } from 'react-redux'; 17 | import { bindActionCreators } from 'redux'; 18 | import { fetchLoggedInUser } from './account/actionsAuth'; 19 | import 'bootstrap/dist/css/bootstrap.min.css'; 20 | import 'react-notifications/dist/react-notifications.css'; 21 | import './App.scss'; 22 | 23 | const mapDispatchToProps = (dispatch) => { 24 | return bindActionCreators({ 25 | fetchLoggedInUser 26 | }, dispatch); 27 | }; 28 | 29 | class App extends React.Component { 30 | state = { 31 | user: null 32 | } 33 | 34 | componentDidMount() { 35 | this.props.fetchLoggedInUser(); 36 | } 37 | 38 | render() { 39 | return ( 40 |
41 | 42 |
43 |
44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | } 58 | /> 59 |
60 |
61 | ); 62 | } 63 | } 64 | 65 | export default connect(null, mapDispatchToProps)(App); 66 | -------------------------------------------------------------------------------- /client/src/App.scss: -------------------------------------------------------------------------------- 1 | .app-container { 2 | padding-top: 16px; 3 | } 4 | 5 | body { 6 | background: #E3E8EE; 7 | font-family: 'Rubik', sans-serif; 8 | } -------------------------------------------------------------------------------- /client/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /client/src/HomePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router'; 3 | import ListGroup from 'react-bootstrap/ListGroup'; 4 | import { Link } from 'react-router-dom'; 5 | import { useSelector } from 'react-redux'; 6 | import _get from 'lodash/get'; 7 | 8 | function HomePage() { 9 | const auth = useSelector(state => state.auth); 10 | const email = _get(auth, 'user.email'); 11 | 12 | return ( 13 |
14 |

{email ? `Welcome ${email}` : 'Welcome Home'}

15 | 16 | 17 | Register 18 | 19 | 20 | Login 21 | 22 | 23 | Logout 24 | 25 | 26 | Activate 27 | 28 | 29 | Resend Activation Link 30 | 31 | 32 | Reset Password Link 33 | 34 | 35 | Reset Password 36 | 37 | 38 | Payments 39 | 40 | 41 | 42 |
43 | ); 44 | } 45 | 46 | export default withRouter(HomePage); 47 | -------------------------------------------------------------------------------- /client/src/account/AccountActivation.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import queryString from 'query-string'; 3 | import Alert from 'react-bootstrap/Alert'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | export default function AccountActivation(props) { 7 | const [busy, setBusy] = useState(false); 8 | const [apiState, setApiState] = useState({ 9 | success: false, 10 | error: false, 11 | errors: [] 12 | }) 13 | console.log('props', props); 14 | 15 | useEffect(() => { 16 | // Get token from URL 17 | const queryStringParams = queryString.parse(props.location.search) || {}; 18 | const token = queryStringParams.token; 19 | 20 | if (!token) { 21 | setBusy(false); 22 | setApiState({ 23 | success: false, 24 | error: true, 25 | errors: [] 26 | }) 27 | 28 | return; 29 | 30 | } 31 | 32 | setBusy(true); 33 | 34 | const url = '/api/account-activate'; 35 | 36 | fetch(url, { 37 | headers: { 38 | 'Accept': 'application/json', 39 | 'Content-Type': 'application/json' 40 | }, 41 | credentials: 'same-origin', 42 | method: 'POST', 43 | body: JSON.stringify({ 44 | activationToken: token 45 | }) 46 | }).then((response) => { 47 | setBusy(false); 48 | if (!response.ok) { 49 | return response.json().then(err => { throw err }) 50 | } 51 | return response.json(); 52 | }).then((results) => { 53 | setApiState({ 54 | success: true, 55 | error: false, 56 | errors: [] 57 | }) 58 | console.log('results ', results); 59 | 60 | }).catch((error) => { 61 | setApiState({ 62 | success: false, 63 | error: true, 64 | errors: error.errors 65 | }) 66 | console.log('error ', error); 67 | }) 68 | }, []); 69 | 70 | const { 71 | success, 72 | error 73 | } = apiState; 74 | 75 | return ( 76 |
77 | { 78 | busy ? 79 |
Activating Account ....
: 80 | null 81 | } 82 | { 83 | error && 84 | ( 85 |
86 | 87 | An error occurred. Perhaps you requested a new token? 88 | 89 |
90 | Reuqest a new Activation Token 91 |
92 | 93 |
94 | ) 95 | 96 | } 97 | 98 | { 99 | success && 100 | ( 101 |
102 | 103 | Successfully activated your account. Please proceed to the Login page to Sign In 104 | 105 |
106 | Login 107 |
108 | 109 |
110 | 111 | 112 | ) 113 | } 114 | 115 |
116 | ) 117 | 118 | } 119 | -------------------------------------------------------------------------------- /client/src/account/Login.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import Cookies from 'js-cookie'; 5 | import { Link } from 'react-router-dom'; 6 | import { NotificationManager } from 'react-notifications'; 7 | import Errors from '../common/Errors'; 8 | import './account.scss'; 9 | 10 | export default function Login(props) { 11 | const [email, setEmail] = useState(''); 12 | const [password, setPassword] = useState(''); 13 | const [apiState, setApiState] = useState({ 14 | success: false, 15 | error: false, 16 | errors: [] 17 | }) 18 | 19 | const handleEmailChange = (event) => { 20 | setEmail(event.target.value); 21 | } 22 | 23 | const handlePasswordChange = (event) => { 24 | setPassword(event.target.value) 25 | } 26 | 27 | const handleSubmitClick = () => { 28 | setApiState({ 29 | success: false, 30 | error: false, 31 | errors: null 32 | }) 33 | 34 | // Submit the email and password to the server 35 | const url = '/api/login'; 36 | 37 | fetch(url, { 38 | headers: { 39 | 'Accept': 'application/json', 40 | 'Content-Type': 'application/json' 41 | }, 42 | credentials: 'same-origin', 43 | method: 'POST', 44 | body: JSON.stringify({ 45 | email: email, 46 | password: password 47 | }) 48 | }).then((response) => { 49 | if (!response.ok) { 50 | return response.json().then(err => { throw err }) 51 | } 52 | return response.json(); 53 | }).then((results) => { 54 | console.log('results ', results); 55 | const token = results.token; 56 | 57 | Cookies.set('token', token, { 58 | expires: 7 59 | }) 60 | NotificationManager.success('You have successfully logged in', 'Login Success'); 61 | setTimeout(() => { 62 | window.location.href = '/'; 63 | }, 2000); 64 | }).catch((error) => { 65 | console.log('error ', error); 66 | setApiState({ 67 | success: false, 68 | error: true, 69 | errors: error.errors 70 | }); 71 | }) 72 | } 73 | 74 | const { 75 | errors 76 | } = apiState; 77 | 78 | return ( 79 |
80 |
81 |

Login

82 |
83 |
84 | { 85 | errors && 86 | } 87 | 88 | Email address 89 | 95 | 96 | We'll never share your email with anyone else. 97 | 98 | 99 | 100 | 101 | Password 102 | 108 | 109 | 116 | 117 |
118 |
119 |
120 |
Don't have an account?
121 |
Register
122 |
123 |
124 |
Trouble Signing Up?
125 |
Reset Password
126 |
127 |
128 |
129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /client/src/account/Logout.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Cookies from 'js-cookie'; 3 | 4 | export default function Logout(props) { 5 | const [isLoggedOut, setIsLoggedOut] = useState(false); 6 | 7 | useEffect(() => { 8 | Cookies.remove('token'); 9 | setIsLoggedOut(true); 10 | }, []); 11 | 12 | return ( 13 |
14 |
15 | { 16 | isLoggedOut ? 17 |
You are logged out
: 18 |
You are logged in
19 | } 20 |
21 |
22 | ); 23 | 24 | } 25 | -------------------------------------------------------------------------------- /client/src/account/Register.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { Link } from 'react-router-dom'; 5 | import Alert from 'react-bootstrap/Alert'; 6 | import Errors from '../common/Errors'; 7 | import './account.scss'; 8 | 9 | export default function Register(props) { 10 | const [email, setEmail] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | const [apiState, setApiState] = useState({ 13 | success: false, 14 | error: false, 15 | errors: [] 16 | }) 17 | 18 | const handleEmailChange = (event) => { 19 | setEmail(event.target.value); 20 | } 21 | 22 | const handlePasswordChange = (event) => { 23 | setPassword(event.target.value) 24 | } 25 | 26 | const handleSubmitClick = () => { 27 | setApiState({ 28 | success: false, 29 | error: false, 30 | errors: null 31 | }) 32 | 33 | // Submit the email and password to the server 34 | const url = '/api/register'; 35 | 36 | fetch(url, { 37 | headers: { 38 | 'Accept': 'application/json', 39 | 'Content-Type': 'application/json' 40 | }, 41 | credentials: 'same-origin', 42 | method: 'POST', 43 | body: JSON.stringify({ 44 | email, 45 | password 46 | }) 47 | }).then((response) => { 48 | if (!response.ok) { 49 | return response.json().then(err => { throw err }) 50 | } 51 | return response.json(); 52 | }).then((results) => { 53 | console.log('results ', results); 54 | setApiState({ 55 | success: true, 56 | error: false, 57 | errors: null 58 | }) 59 | }).catch((error) => { 60 | console.log('error ', error); 61 | setApiState({ 62 | success: false, 63 | error: true, 64 | errors: error.errors 65 | }) 66 | }) 67 | } 68 | 69 | const { 70 | success, 71 | errors 72 | } = apiState; 73 | 74 | return ( 75 |
76 |
77 |

Register

78 |
79 |
80 | { 81 | success && 82 | 83 |
Successfully registered your account.
84 |
You need to confirm your email address before logging in.
85 |
86 | 87 | } 88 | { 89 | errors && 90 | } 91 | 92 | Email address 93 | 99 | 100 | We'll never share your email with anyone else. 101 | 102 | 103 | 104 | 105 | Password 106 | 112 | 113 | 120 | 121 |
122 |
123 |
Already have an account?
124 |
Login
125 |
126 |
127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /client/src/account/ResendActivationLink.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { Link } from 'react-router-dom'; 5 | import Alert from 'react-bootstrap/Alert'; 6 | import Errors from '../common/Errors'; 7 | import './account.scss'; 8 | 9 | export default function ResendActivationLink(props) { 10 | const [email, setEmail] = useState(''); 11 | const [apiState, setApiState] = useState({ 12 | success: false, 13 | error: false, 14 | errors: [] 15 | }) 16 | 17 | const handleEmailChange = (event) => { 18 | setEmail(event.target.value); 19 | } 20 | 21 | const handleSubmitClick = () => { 22 | setApiState({ 23 | success: false, 24 | error: false, 25 | errors: null 26 | }) 27 | 28 | // Submit the email and password to the server 29 | const url = '/api/resend-activation-link'; 30 | 31 | fetch(url, { 32 | headers: { 33 | 'Accept': 'application/json', 34 | 'Content-Type': 'application/json' 35 | }, 36 | credentials: 'same-origin', 37 | method: 'POST', 38 | body: JSON.stringify({ 39 | email 40 | }) 41 | }).then((response) => { 42 | if (!response.ok) { 43 | return response.json().then(err => { throw err }) 44 | } 45 | return response.json(); 46 | }).then((results) => { 47 | console.log('results ', results); 48 | setApiState({ 49 | success: true, 50 | error: false, 51 | errors: null 52 | }) 53 | 54 | }).catch((error) => { 55 | console.log('error ', error); 56 | setApiState({ 57 | success: false, 58 | error: true, 59 | errors: error.errors 60 | }) 61 | 62 | }) 63 | } 64 | 65 | const { 66 | success, 67 | errors 68 | } = apiState; 69 | 70 | return ( 71 |
72 |
73 |

Resend Activation Link

74 |
75 |
76 | { 77 | success && 78 | 79 |
Activation Link has been sent
80 |
81 | 82 | } 83 | { 84 | errors && 85 | } 86 | 87 | Email address 88 | 94 | 95 | This account should already exist in our database. 96 | 97 | 98 | 99 | 106 | 107 | 108 |
109 |
110 |
111 |
Already have an account?
112 |
Login
113 |
114 |
115 |
Don't have an account?
116 |
Register
117 |
118 | 119 |
120 |
121 |
122 | ) 123 | } 124 | 125 | -------------------------------------------------------------------------------- /client/src/account/ResetPassword.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import queryString from 'query-string'; 3 | import Form from 'react-bootstrap/Form'; 4 | import Button from 'react-bootstrap/Button'; 5 | import { Link } from 'react-router-dom'; 6 | import Alert from 'react-bootstrap/Alert'; 7 | import Errors from '../common/Errors'; 8 | import './account.scss'; 9 | 10 | export default function ResetPassword(props) { 11 | const [password, setPassword] = useState(''); 12 | const [resetPasswordToken, setResetPasswordToken] = useState(''); 13 | const [apiState, setApiState] = useState({ 14 | success: false, 15 | error: false, 16 | errors: [] 17 | }) 18 | 19 | const handlePasswordChange = (event) => { 20 | setPassword(event.target.value) 21 | } 22 | 23 | useEffect(() => { 24 | // Get token from URL 25 | const queryStringParams = queryString.parse(props.location.search) || {}; 26 | const token = queryStringParams.token; 27 | 28 | if (!token) { 29 | setApiState({ 30 | success: false, 31 | error: true, 32 | errors: [{ 33 | message: 'You need to generate a Reset Password Token before resetting your password' 34 | }] 35 | }) 36 | 37 | 38 | } 39 | 40 | setResetPasswordToken(token); 41 | }, []); 42 | 43 | const handleSubmitClick = () => { 44 | setApiState({ 45 | success: false, 46 | error: false, 47 | errors: null 48 | }) 49 | 50 | // Submit the email and password to the server 51 | const url = '/api/reset-password'; 52 | 53 | fetch(url, { 54 | headers: { 55 | 'Accept': 'application/json', 56 | 'Content-Type': 'application/json' 57 | }, 58 | credentials: 'same-origin', 59 | method: 'POST', 60 | body: JSON.stringify({ 61 | resetPasswordToken: resetPasswordToken, 62 | password: password 63 | }) 64 | }).then((response) => { 65 | if (!response.ok) { 66 | return response.json().then(err => { throw err }) 67 | } 68 | return response.json(); 69 | }).then((results) => { 70 | console.log('results ', results); 71 | setApiState({ 72 | success: true, 73 | error: false, 74 | errors: null 75 | }) 76 | 77 | }).catch((error) => { 78 | console.log('error ', error); 79 | setApiState({ 80 | success: false, 81 | error: true, 82 | errors: error.errors 83 | }) 84 | }) 85 | 86 | } 87 | 88 | const { 89 | success, 90 | errors 91 | } = apiState; 92 | 93 | return ( 94 |
95 |
96 |

Reset Password

97 |
98 |
99 | { 100 | success && 101 | 102 |
Your password has been updated. Please proceed to the login page to Sign In
103 |
Login
104 |
105 | } 106 | { 107 | errors && 108 | } 109 | 110 | Password 111 | 117 | 118 | Please enter your new password 119 | 120 | 121 | 122 | 129 | 130 | 131 |
132 |
133 |
134 |
Try Login again?
135 |
Login
136 |
137 | 138 |
139 |
140 |
141 | ); 142 | 143 | } 144 | -------------------------------------------------------------------------------- /client/src/account/ResetPasswordLink.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { Link } from 'react-router-dom'; 5 | import Alert from 'react-bootstrap/Alert'; 6 | import Errors from '../common/Errors'; 7 | import './account.scss'; 8 | 9 | export default function ResetPasswordLink(props) { 10 | const [email, setEmail] = useState(''); 11 | const [apiState, setApiState] = useState({ 12 | success: false, 13 | error: false, 14 | errors: [] 15 | }) 16 | 17 | const handleEmailChange = (event) => { 18 | setEmail(event.target.value); 19 | } 20 | 21 | const handleSubmitClick = () => { 22 | setApiState({ 23 | success: false, 24 | error: false, 25 | errors: null 26 | }) 27 | 28 | // Submit the email and password to the server 29 | const url = '/api/reset-password-link'; 30 | 31 | fetch(url, { 32 | headers: { 33 | 'Accept': 'application/json', 34 | 'Content-Type': 'application/json' 35 | }, 36 | credentials: 'same-origin', 37 | method: 'POST', 38 | body: JSON.stringify({ 39 | email 40 | }) 41 | }).then((response) => { 42 | if (!response.ok) { 43 | return response.json().then(err => { throw err }) 44 | } 45 | return response.json(); 46 | }).then((results) => { 47 | console.log('results ', results); 48 | setApiState({ 49 | success: true, 50 | error: false, 51 | errors: null 52 | }) 53 | 54 | }).catch((error) => { 55 | console.log('error ', error); 56 | setApiState({ 57 | success: false, 58 | error: true, 59 | errors: error.errors 60 | }) 61 | }) 62 | } 63 | const { 64 | success, 65 | errors 66 | } = apiState; 67 | 68 | return ( 69 |
70 |
71 |

Reset Password Link

72 |
73 |
74 | { 75 | success && 76 | 77 |
Please check your email for the link to reset your password
78 |
79 | 80 | } 81 | { 82 | errors && 83 | } 84 | 85 | Email address 86 | 92 | 93 | This account should already exist in our database. 94 | 95 | 96 | 97 | 104 | 105 | 106 |
107 |
108 |
Try Login again?
109 |
Login
110 |
111 | 112 |
113 |
114 |
115 |
116 | ); 117 | 118 | 119 | } 120 | -------------------------------------------------------------------------------- /client/src/account/account.scss: -------------------------------------------------------------------------------- 1 | .form-container { 2 | max-width: 340px; 3 | margin: 0 auto; 4 | background: white; 5 | padding: 16px; 6 | } 7 | 8 | .actions-container { 9 | margin-top: 24px; 10 | } -------------------------------------------------------------------------------- /client/src/account/actionsAuth.js: -------------------------------------------------------------------------------- 1 | export const AUTH_BUSY = 'AUTH_BUSY'; 2 | export const AUTH_RETRIEVEUSER_SUCCESS = 'AUTH_RETRIEVEUSER_SUCCESS'; 3 | export const AUTH_RETRIEVEUSER_ERROR = 'AUTH_RETRIEVEUSER_ERROR'; 4 | 5 | export function fetchLoggedInUser() { 6 | return function(dispatch, getState) { 7 | dispatch({ 8 | type: AUTH_BUSY 9 | }); 10 | const url = '/api/logged-in-user'; 11 | 12 | fetch(url, { 13 | headers: { 14 | 'Accept': 'application/json', 15 | 'Content-Type': 'application/json' 16 | }, 17 | credentials: 'same-origin', 18 | method: 'GET' 19 | }).then((response) => { 20 | if (!response.ok) { 21 | return response.json().then(err => { throw err }) 22 | } 23 | return response.json(); 24 | }).then((results) => { 25 | console.log('results ', results); 26 | const user = results.user; 27 | 28 | dispatch({ 29 | type: AUTH_RETRIEVEUSER_SUCCESS, 30 | payload: user 31 | }); 32 | }).catch((error) => { 33 | console.log('error ', error); 34 | dispatch({ 35 | type: AUTH_RETRIEVEUSER_ERROR, 36 | payload: null 37 | }); 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/account/reducerAuth.js: -------------------------------------------------------------------------------- 1 | import _isEmpty from 'lodash/isEmpty'; 2 | 3 | import { 4 | AUTH_BUSY, 5 | AUTH_RETRIEVEUSER_SUCCESS, 6 | AUTH_RETRIEVEUSER_ERROR 7 | } from './actionsAuth'; 8 | 9 | export default function auth(state = {}, action) { 10 | switch(action.type) { 11 | case AUTH_BUSY: { 12 | return { 13 | ...state, 14 | busy: true 15 | } 16 | } 17 | 18 | case AUTH_RETRIEVEUSER_ERROR: { 19 | return { 20 | ...state, 21 | busy: false, 22 | error: null, 23 | authenticated: false, 24 | user: null 25 | } 26 | } 27 | case AUTH_RETRIEVEUSER_SUCCESS: { 28 | const { payload = {} } = action; 29 | return { 30 | ...state, 31 | busy: false, 32 | error: null, 33 | authenticated: !_isEmpty(payload), 34 | user: payload 35 | } 36 | } 37 | default: return state; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/common/Errors.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Alert from 'react-bootstrap/Alert'; 3 | import _map from 'lodash/map'; 4 | 5 | export default class Errors extends React.Component { 6 | render() { 7 | const { 8 | errors 9 | } = this.props; 10 | 11 | const errorMessages = _map(errors, 'message'); 12 | 13 | return ( 14 |
15 | { 16 | _map(errorMessages, (error, index) => { 17 | return ( 18 | 19 | {error} 20 | 21 | ) 22 | }) 23 | } 24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/header/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Navbar from 'react-bootstrap/Navbar'; 3 | import Nav from 'react-bootstrap/Nav'; 4 | 5 | export class Header extends Component { 6 | render() { 7 | return ( 8 |
9 | <> 10 | 11 | SAASIFY 12 | 17 | 18 | 19 |
20 | ); 21 | } 22 | } 23 | 24 | export default Header; 25 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import store from './store/configureStore'; 5 | import './index.css'; 6 | import App from './App'; 7 | import * as serviceWorker from './serviceWorker'; 8 | import { 9 | BrowserRouter as Router 10 | } from 'react-router-dom'; 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | 18 | , document.getElementById('root')); 19 | 20 | // If you want your app to work offline and load faster, you can change 21 | // unregister() to register() below. Note this comes with some pitfalls. 22 | // Learn more about service workers: https://bit.ly/CRA-PWA 23 | serviceWorker.unregister(); 24 | -------------------------------------------------------------------------------- /client/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/payments/CreateSubscription.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import StripeProviderComponent from './StripeProviderComponent'; 5 | import Card from 'react-bootstrap/Card'; 6 | import _get from 'lodash/get'; 7 | 8 | export default class CreateSubscription extends React.Component { 9 | handlePlanChange = (event) => { 10 | this.props.setPlan(event.target.value) 11 | } 12 | 13 | createSubscription = () => { 14 | this.props.onPaymentMethodCreated(); 15 | } 16 | 17 | render() { 18 | const { 19 | user 20 | } = this.props; 21 | 22 | const hasSubscription = _get(user, 'stripeDetails.subscription.status', '') === 'active'; 23 | 24 | return ( 25 | 26 | 27 | { 28 | hasSubscription ? 29 |

Update your subscription

: 30 |

Subscribe to a plan

31 | } 32 |
33 |
34 | 35 | Select your plan 36 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | { 49 | !this.props.card && 50 | 55 | } 56 | 57 | { 58 | this.props.card && 59 | 66 | } 67 | 68 | 69 | 70 |
71 |
72 |
73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /client/src/payments/CreditCardForm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Use the CSS tab above to style your Element's container. 3 | */ 4 | import React from 'react'; 5 | import { CardElement, injectStripe } from 'react-stripe-elements'; 6 | import Button from 'react-bootstrap/Button'; 7 | 8 | const style = { 9 | base: { 10 | color: "#32325d", 11 | fontFamily: '"Helvetica Neue", Helvetica, sans-serif', 12 | fontSmoothing: "antialiased", 13 | fontSize: "16px", 14 | "::placeholder": { 15 | color: "#aab7c4" 16 | } 17 | }, 18 | invalid: { 19 | color: "#fa755a", 20 | iconColor: "#fa755a" 21 | } 22 | }; 23 | 24 | class CardSection extends React.Component { 25 | handleSubmitClick = async (event) => { 26 | 27 | const cardElement = this.props.elements.getElement('card'); 28 | 29 | const { paymentMethod, error } = await this.props.stripe.createPaymentMethod({ 30 | type: 'card', 31 | card: cardElement, 32 | billing_details: { 33 | email: this.props.user.email, 34 | }, 35 | }); 36 | 37 | console.log('paymentMethod', paymentMethod); 38 | console.log('error', error); 39 | 40 | if (!error) { 41 | this.props.onPaymentMethodCreated(paymentMethod); 42 | } 43 | } 44 | 45 | render() { 46 | return ( 47 |
48 |
49 |
50 | Card details 51 | 52 |
53 | 54 | 61 |
62 |
63 | ); 64 | } 65 | }; 66 | 67 | export default injectStripe(CardSection); 68 | -------------------------------------------------------------------------------- /client/src/payments/CurrentCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _get from 'lodash/get'; 3 | import Card from 'react-bootstrap/Card'; 4 | 5 | export default class CurrentCard extends React.Component { 6 | render() { 7 | const { 8 | card 9 | } = this.props; 10 | 11 | const brand = _get(card, 'brand'); 12 | const exp_month = _get(card, 'exp_month'); 13 | const exp_year = _get(card, 'exp_year'); 14 | const last4 = _get(card, 'last4'); 15 | 16 | if (!card) { 17 | return ( 18 | 19 | 20 |

Card details

21 |
You do not have any saved cards.
22 |
23 |
24 | ) 25 | } 26 | 27 | return ( 28 | 29 | 30 |

Card details

31 |
32 |
Brand
33 |

{brand}

34 |
Expiry Month
35 |

{exp_month}

36 |
Expiry Year
37 |

{exp_year}

38 |
Last 4
39 |

{last4}

40 |
41 |
42 |
43 | 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/payments/CurrentSubscription.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from 'react-bootstrap/Card'; 3 | import Button from 'react-bootstrap/Button'; 4 | 5 | export default class CurrentSubscription extends React.Component { 6 | render() { 7 | const { 8 | subscription 9 | } = this.props; 10 | if (!subscription) { 11 | return ( 12 | 13 | 14 |

Subscription details

15 |
You do not have any subscriptions yet.
16 |
17 |
18 | ) 19 | } 20 | 21 | const nickname = subscription.plan.nickname; 22 | const amount = subscription.plan.amount; 23 | const interval = subscription.plan.interval; 24 | const status = subscription.status; 25 | 26 | 27 | return ( 28 | 29 | 30 |

Subscription details

31 |
32 |
Status
33 |

{status}

34 |
You are subscribed to
35 |

{nickname}

36 |
We will charge you
37 |

{amount}

38 |
Interval
39 |

{interval}

40 |
41 | 49 |
50 |
51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/src/payments/Payments.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CurrentSubscription from './CurrentSubscription'; 3 | import CurrentCard from './CurrentCard'; 4 | import CreateSubscription from './CreateSubscription'; 5 | import UpdateCard from './UpdateCard'; 6 | import _get from 'lodash/get'; 7 | 8 | const stripe = Stripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY); // eslint-disable-line 9 | 10 | export default class Payments extends React.Component { 11 | state = { 12 | subscription: null, 13 | card: null, 14 | plan: '' 15 | } 16 | componentDidMount() { 17 | this.getPaymentDetails(); 18 | } 19 | 20 | getPaymentDetails = () => { 21 | this.getSubscription(); 22 | this.getCard(); 23 | } 24 | 25 | setPlan = (planId) => { 26 | this.setState({ 27 | plan: planId 28 | }) 29 | } 30 | 31 | createSubscription = (paymentMethod) => { 32 | if (!this.state.plan) { 33 | // Show error to user 34 | return; 35 | } 36 | 37 | const planId = this.state.plan; 38 | 39 | const url = '/api/payments/subscriptions'; 40 | 41 | fetch(url, { 42 | headers: { 43 | 'Accept': 'application/json', 44 | 'Content-Type': 'application/json' 45 | }, 46 | credentials: 'same-origin', 47 | method: 'POST', 48 | body: JSON.stringify({ 49 | planId: planId, 50 | paymentMethod 51 | }) 52 | }).then((response) => { 53 | if (!response.ok) { 54 | return response.json().then(err => { throw err }) 55 | } 56 | return response.json(); 57 | }).then((results) => { 58 | console.log('results ', results); 59 | const subscription = _get(results, 'subscription'); 60 | const paymentIntentStatus = _get(subscription, 'latest_invoice.payment_intent.status', ''); 61 | const client_secret = _get(subscription, 'latest_invoice.payment_intent.client_secret', ''); 62 | if (paymentIntentStatus === 'requires_action' || paymentIntentStatus === 'requires_payment_method') { 63 | stripe.confirmCardPayment(client_secret).then(function(result) { 64 | console.log('confirmCardPayment result', result); 65 | if (result.error) { 66 | // Display error message in your UI. 67 | // The card was declined (i.e. insufficient funds, card has expired, etc) 68 | alert(result.error); 69 | 70 | } else { 71 | // Show a success message to your customer 72 | alert('You have successfully confirmed the payment'); 73 | window.location.href = '/payments'; 74 | } 75 | 76 | 77 | }); 78 | } 79 | 80 | // this.getPaymentDetails(); 81 | 82 | }).catch((error) => { 83 | console.log('error ', error); 84 | // this.getPaymentDetails(); 85 | window.location.href = '/payments'; 86 | }) 87 | } 88 | 89 | updateCard = (paymentMethod) => { 90 | const url = '/api/payments/cards'; 91 | 92 | fetch(url, { 93 | headers: { 94 | 'Accept': 'application/json', 95 | 'Content-Type': 'application/json' 96 | }, 97 | credentials: 'same-origin', 98 | method: 'POST', 99 | body: JSON.stringify({ 100 | paymentMethod 101 | }) 102 | }).then((response) => { 103 | if (!response.ok) { 104 | return response.json().then(err => { throw err }) 105 | } 106 | return response.json(); 107 | }).then((results) => { 108 | console.log('results ', results); 109 | // this.getPaymentDetails(); 110 | window.location.href = '/payments'; 111 | 112 | }).catch((error) => { 113 | console.log('error ', error); 114 | // this.getPaymentDetails(); 115 | window.location.href = '/payments'; 116 | }) 117 | } 118 | 119 | deleteSubscription = () => { 120 | const url = '/api/payments/subscriptions'; 121 | 122 | fetch(url, { 123 | headers: { 124 | 'Accept': 'application/json', 125 | 'Content-Type': 'application/json' 126 | }, 127 | credentials: 'same-origin', 128 | method: 'DELETE' 129 | }).then((response) => { 130 | if (!response.ok) { 131 | return response.json().then(err => { throw err }) 132 | } 133 | return response.json(); 134 | }).then((results) => { 135 | console.log('results ', results); 136 | // this.getPaymentDetails(); 137 | window.location.href = '/payments'; 138 | 139 | }).catch((error) => { 140 | console.log('error ', error); 141 | // this.getPaymentDetails(); 142 | window.location.href = '/payments'; 143 | }) 144 | } 145 | 146 | getSubscription = (paymentMethod) => { 147 | const url = '/api/payments/subscriptions'; 148 | 149 | fetch(url, { 150 | headers: { 151 | 'Accept': 'application/json', 152 | 'Content-Type': 'application/json' 153 | }, 154 | credentials: 'same-origin', 155 | method: 'GET' 156 | }).then((response) => { 157 | if (!response.ok) { 158 | return response.json().then(err => { throw err }) 159 | } 160 | return response.json(); 161 | }).then((results) => { 162 | console.log('results ', results); 163 | this.setState({ 164 | subscription: results.subscription 165 | }) 166 | 167 | }).catch((error) => { 168 | console.log('error ', error); 169 | }) 170 | } 171 | 172 | getCard = () => { 173 | const url = '/api/payments/cards'; 174 | 175 | fetch(url, { 176 | headers: { 177 | 'Accept': 'application/json', 178 | 'Content-Type': 'application/json' 179 | }, 180 | credentials: 'same-origin', 181 | method: 'GET' 182 | }).then((response) => { 183 | if (!response.ok) { 184 | return response.json().then(err => { throw err }) 185 | } 186 | return response.json(); 187 | }).then((results) => { 188 | console.log('results ', results); 189 | this.setState({ 190 | card: results.card 191 | }) 192 | 193 | }).catch((error) => { 194 | console.log('error ', error); 195 | }) 196 | } 197 | 198 | render() { 199 | const customer = _get(this.props.user, 'stripeDetails.customer'); 200 | 201 | return ( 202 |
203 |
204 |

Payments

205 | 206 |
207 |
208 | 212 |
213 |
214 | 215 |
216 |
217 | 218 | 219 | 220 | { 221 | customer && 222 | 227 | } 228 | 229 | 237 |
238 |
239 | ); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /client/src/payments/StripeProviderComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StripeProvider, Elements } from 'react-stripe-elements'; 3 | import CreditCardForm from './CreditCardForm'; 4 | 5 | export default class StripeProviderComponent extends React.Component { 6 | render() { 7 | return ( 8 |
9 | 10 | 11 | 16 | 17 | 18 |
19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/payments/UpdateCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from 'react-bootstrap/Card'; 3 | import StripeProviderComponent from './StripeProviderComponent'; 4 | 5 | export default class UpdateCard extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 |

Update Card

11 |
12 | 17 |
18 |
19 |
20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /client/src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, compose, createStore } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { createLogger } from 'redux-logger'; 4 | import rootReducer from './rootReducer'; 5 | 6 | let store; 7 | 8 | const configureStore = (initialState) => { 9 | const middleware = []; 10 | // const enhancers = []; 11 | 12 | // THUNK 13 | middleware.push(thunk); 14 | 15 | // Logging Middleware 16 | const logger = createLogger({ 17 | level: 'info', 18 | collapsed: true 19 | }); 20 | middleware.push(logger); 21 | 22 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 23 | 24 | store = createStore(rootReducer, initialState, composeEnhancers( 25 | applyMiddleware(...middleware) 26 | )); 27 | 28 | } 29 | 30 | 31 | if (!store) { 32 | configureStore(); 33 | } 34 | 35 | export default store; 36 | -------------------------------------------------------------------------------- /client/src/store/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import auth from '../account/reducerAuth'; 3 | 4 | const rootReducer = combineReducers({ 5 | auth 6 | }); 7 | 8 | export default rootReducer; 9 | -------------------------------------------------------------------------------- /client2/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_cPAYuvk1DKJ0cqm0cm0Zblms 2 | -------------------------------------------------------------------------------- /client2/.env-sample: -------------------------------------------------------------------------------- 1 | REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE 2 | -------------------------------------------------------------------------------- /client2/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client2/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /client2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client2", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.3", 7 | "@testing-library/react": "^12.1.4", 8 | "@testing-library/user-event": "^13.5.0", 9 | "bootstrap": "^5.1.3", 10 | "js-cookie": "^3.0.1", 11 | "lodash": "^4.17.21", 12 | "query-string": "^7.1.1", 13 | "react": "^18.0.0", 14 | "react-bootstrap": "^2.2.2", 15 | "react-dom": "^18.0.0", 16 | "react-notifications": "^1.7.3", 17 | "react-redux": "^7.2.8", 18 | "react-router-dom": "^5.3.0", 19 | "react-scripts": "5.0.0", 20 | "react-stripe-elements": "^6.1.2", 21 | "redux": "^4.1.2", 22 | "redux-logger": "^3.0.6", 23 | "redux-thunk": "^2.4.1", 24 | "sass": "^1.49.10", 25 | "web-vitals": "^2.1.4" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": [ 35 | "react-app", 36 | "react-app/jest" 37 | ] 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "proxy": "http://localhost:3001", 52 | "engines": { 53 | "node": ">=14.0.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client2/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/client2/public/favicon.ico -------------------------------------------------------------------------------- /client2/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 28 | 29 | 30 | 31 | 32 | SaasiFy 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /client2/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/client2/public/logo192.png -------------------------------------------------------------------------------- /client2/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/client2/public/logo512.png -------------------------------------------------------------------------------- /client2/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client2/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client2/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route 4 | } from 'react-router-dom'; 5 | import HomePage from './HomePage'; 6 | import Register from './account/Register'; 7 | import AccountActivation from './account/AccountActivation'; 8 | import ResendActivationLink from './account/ResendActivationLink'; 9 | import ResetPasswordLink from './account/ResetPasswordLink'; 10 | import ResetPassword from './account/ResetPassword'; 11 | import Login from './account/Login'; 12 | import Logout from './account/Logout'; 13 | import Header from './header/Header' 14 | import Payments from './payments/Payments'; 15 | import { NotificationContainer } from 'react-notifications'; 16 | import { connect } from 'react-redux'; 17 | import { bindActionCreators } from 'redux'; 18 | import { fetchLoggedInUser } from './account/actionsAuth'; 19 | import 'bootstrap/dist/css/bootstrap.min.css'; 20 | import 'react-notifications/dist/react-notifications.css'; 21 | import './App.scss'; 22 | 23 | const mapDispatchToProps = (dispatch) => { 24 | return bindActionCreators({ 25 | fetchLoggedInUser 26 | }, dispatch); 27 | }; 28 | 29 | class App extends React.Component { 30 | state = { 31 | user: null 32 | } 33 | 34 | componentDidMount() { 35 | this.props.fetchLoggedInUser(); 36 | } 37 | 38 | render() { 39 | return ( 40 |
41 | 42 |
43 |
44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | } 58 | /> 59 |
60 |
61 | ); 62 | } 63 | } 64 | 65 | export default connect(null, mapDispatchToProps)(App); 66 | -------------------------------------------------------------------------------- /client2/src/App.scss: -------------------------------------------------------------------------------- 1 | .app-container { 2 | padding-top: 16px; 3 | } 4 | 5 | body { 6 | background: #E3E8EE; 7 | font-family: 'Rubik', sans-serif; 8 | } -------------------------------------------------------------------------------- /client2/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /client2/src/HomePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router'; 3 | import ListGroup from 'react-bootstrap/ListGroup'; 4 | import { Link } from 'react-router-dom'; 5 | import { useSelector } from 'react-redux'; 6 | import _get from 'lodash/get'; 7 | 8 | function HomePage() { 9 | const auth = useSelector(state => state.auth); 10 | const email = _get(auth, 'user.email'); 11 | 12 | return ( 13 |
14 |

{email ? `Welcome ${email}` : 'Welcome Home'}

15 | 16 | 17 | Register 18 | 19 | 20 | Login 21 | 22 | 23 | Logout 24 | 25 | 26 | Activate 27 | 28 | 29 | Resend Activation Link 30 | 31 | 32 | Reset Password Link 33 | 34 | 35 | Reset Password 36 | 37 | 38 | Payments 39 | 40 | 41 | 42 |
43 | ); 44 | } 45 | 46 | export default withRouter(HomePage); 47 | -------------------------------------------------------------------------------- /client2/src/account/AccountActivation.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import queryString from 'query-string'; 3 | import Alert from 'react-bootstrap/Alert'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | export default function AccountActivation(props) { 7 | const [busy, setBusy] = useState(false); 8 | const [apiState, setApiState] = useState({ 9 | success: false, 10 | error: false, 11 | errors: [] 12 | }) 13 | console.log('props', props); 14 | 15 | useEffect(() => { 16 | // Get token from URL 17 | const queryStringParams = queryString.parse(props.location.search) || {}; 18 | const token = queryStringParams.token; 19 | 20 | if (!token) { 21 | setBusy(false); 22 | setApiState({ 23 | success: false, 24 | error: true, 25 | errors: [] 26 | }) 27 | 28 | return; 29 | 30 | } 31 | 32 | setBusy(true); 33 | 34 | const url = '/api/account-activate'; 35 | 36 | fetch(url, { 37 | headers: { 38 | 'Accept': 'application/json', 39 | 'Content-Type': 'application/json' 40 | }, 41 | credentials: 'same-origin', 42 | method: 'POST', 43 | body: JSON.stringify({ 44 | activationToken: token 45 | }) 46 | }).then((response) => { 47 | setBusy(false); 48 | if (!response.ok) { 49 | return response.json().then(err => { throw err }) 50 | } 51 | return response.json(); 52 | }).then((results) => { 53 | setApiState({ 54 | success: true, 55 | error: false, 56 | errors: [] 57 | }) 58 | console.log('results ', results); 59 | 60 | }).catch((error) => { 61 | setApiState({ 62 | success: false, 63 | error: true, 64 | errors: error.errors 65 | }) 66 | console.log('error ', error); 67 | }) 68 | }, []); 69 | 70 | const { 71 | success, 72 | error 73 | } = apiState; 74 | 75 | return ( 76 |
77 | { 78 | busy ? 79 |
Activating Account ....
: 80 | null 81 | } 82 | { 83 | error && 84 | ( 85 |
86 | 87 | An error occurred. Perhaps you requested a new token? 88 | 89 |
90 | Reuqest a new Activation Token 91 |
92 | 93 |
94 | ) 95 | 96 | } 97 | 98 | { 99 | success && 100 | ( 101 |
102 | 103 | Successfully activated your account. Please proceed to the Login page to Sign In 104 | 105 |
106 | Login 107 |
108 | 109 |
110 | 111 | 112 | ) 113 | } 114 | 115 |
116 | ) 117 | 118 | } 119 | -------------------------------------------------------------------------------- /client2/src/account/Login.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import Cookies from 'js-cookie'; 5 | import { Link } from 'react-router-dom'; 6 | import { NotificationManager } from 'react-notifications'; 7 | import Errors from '../common/Errors'; 8 | import './account.scss'; 9 | 10 | export default function Login(props) { 11 | const [email, setEmail] = useState(''); 12 | const [password, setPassword] = useState(''); 13 | const [apiState, setApiState] = useState({ 14 | success: false, 15 | error: false, 16 | errors: [] 17 | }) 18 | 19 | const handleEmailChange = (event) => { 20 | setEmail(event.target.value); 21 | } 22 | 23 | const handlePasswordChange = (event) => { 24 | setPassword(event.target.value) 25 | } 26 | 27 | const handleSubmitClick = () => { 28 | setApiState({ 29 | success: false, 30 | error: false, 31 | errors: null 32 | }) 33 | 34 | // Submit the email and password to the server 35 | const url = '/api/login'; 36 | 37 | fetch(url, { 38 | headers: { 39 | 'Accept': 'application/json', 40 | 'Content-Type': 'application/json' 41 | }, 42 | credentials: 'same-origin', 43 | method: 'POST', 44 | body: JSON.stringify({ 45 | email: email, 46 | password: password 47 | }) 48 | }).then((response) => { 49 | if (!response.ok) { 50 | return response.json().then(err => { throw err }) 51 | } 52 | return response.json(); 53 | }).then((results) => { 54 | console.log('results ', results); 55 | const token = results.token; 56 | 57 | Cookies.set('token', token, { 58 | expires: 7 59 | }) 60 | NotificationManager.success('You have successfully logged in', 'Login Success'); 61 | setTimeout(() => { 62 | window.location.href = '/'; 63 | }, 2000); 64 | }).catch((error) => { 65 | console.log('error ', error); 66 | setApiState({ 67 | success: false, 68 | error: true, 69 | errors: error.errors 70 | }); 71 | }) 72 | } 73 | 74 | const { 75 | errors 76 | } = apiState; 77 | 78 | return ( 79 |
80 |
81 |

Login

82 |
83 |
84 | { 85 | errors && 86 | } 87 | 88 | Email address 89 | 95 | 96 | We'll never share your email with anyone else. 97 | 98 | 99 | 100 | 101 | Password 102 | 108 | 109 | 116 | 117 |
118 |
119 |
120 |
Don't have an account?
121 |
Register
122 |
123 |
124 |
Trouble Signing Up?
125 |
Reset Password
126 |
127 |
128 |
129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /client2/src/account/Logout.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Cookies from 'js-cookie'; 3 | 4 | export default function Logout(props) { 5 | const [isLoggedOut, setIsLoggedOut] = useState(false); 6 | 7 | useEffect(() => { 8 | Cookies.remove('token'); 9 | setIsLoggedOut(true); 10 | }, []); 11 | 12 | return ( 13 |
14 |
15 | { 16 | isLoggedOut ? 17 |
You are logged out
: 18 |
You are logged in
19 | } 20 |
21 |
22 | ); 23 | 24 | } 25 | -------------------------------------------------------------------------------- /client2/src/account/Register.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { Link } from 'react-router-dom'; 5 | import Alert from 'react-bootstrap/Alert'; 6 | import Errors from '../common/Errors'; 7 | import './account.scss'; 8 | 9 | export default function Register(props) { 10 | const [email, setEmail] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | const [apiState, setApiState] = useState({ 13 | success: false, 14 | error: false, 15 | errors: [] 16 | }) 17 | 18 | const handleEmailChange = (event) => { 19 | setEmail(event.target.value); 20 | } 21 | 22 | const handlePasswordChange = (event) => { 23 | setPassword(event.target.value) 24 | } 25 | 26 | const handleSubmitClick = () => { 27 | setApiState({ 28 | success: false, 29 | error: false, 30 | errors: null 31 | }) 32 | 33 | // Submit the email and password to the server 34 | const url = '/api/register'; 35 | 36 | fetch(url, { 37 | headers: { 38 | 'Accept': 'application/json', 39 | 'Content-Type': 'application/json' 40 | }, 41 | credentials: 'same-origin', 42 | method: 'POST', 43 | body: JSON.stringify({ 44 | email, 45 | password 46 | }) 47 | }).then((response) => { 48 | if (!response.ok) { 49 | return response.json().then(err => { throw err }) 50 | } 51 | return response.json(); 52 | }).then((results) => { 53 | console.log('results ', results); 54 | setApiState({ 55 | success: true, 56 | error: false, 57 | errors: null 58 | }) 59 | }).catch((error) => { 60 | console.log('error ', error); 61 | setApiState({ 62 | success: false, 63 | error: true, 64 | errors: error.errors 65 | }) 66 | }) 67 | } 68 | 69 | const { 70 | success, 71 | errors 72 | } = apiState; 73 | 74 | return ( 75 |
76 |
77 |

Register

78 |
79 |
80 | { 81 | success && 82 | 83 |
Successfully registered your account.
84 |
You need to confirm your email address before logging in.
85 |
86 | 87 | } 88 | { 89 | errors && 90 | } 91 | 92 | Email address 93 | 99 | 100 | We'll never share your email with anyone else. 101 | 102 | 103 | 104 | 105 | Password 106 | 112 | 113 | 120 | 121 |
122 |
123 |
Already have an account?
124 |
Login
125 |
126 |
127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /client2/src/account/ResendActivationLink.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { Link } from 'react-router-dom'; 5 | import Alert from 'react-bootstrap/Alert'; 6 | import Errors from '../common/Errors'; 7 | import './account.scss'; 8 | 9 | export default function ResendActivationLink(props) { 10 | const [email, setEmail] = useState(''); 11 | const [apiState, setApiState] = useState({ 12 | success: false, 13 | error: false, 14 | errors: [] 15 | }) 16 | 17 | const handleEmailChange = (event) => { 18 | setEmail(event.target.value); 19 | } 20 | 21 | const handleSubmitClick = () => { 22 | setApiState({ 23 | success: false, 24 | error: false, 25 | errors: null 26 | }) 27 | 28 | // Submit the email and password to the server 29 | const url = '/api/resend-activation-link'; 30 | 31 | fetch(url, { 32 | headers: { 33 | 'Accept': 'application/json', 34 | 'Content-Type': 'application/json' 35 | }, 36 | credentials: 'same-origin', 37 | method: 'POST', 38 | body: JSON.stringify({ 39 | email 40 | }) 41 | }).then((response) => { 42 | if (!response.ok) { 43 | return response.json().then(err => { throw err }) 44 | } 45 | return response.json(); 46 | }).then((results) => { 47 | console.log('results ', results); 48 | setApiState({ 49 | success: true, 50 | error: false, 51 | errors: null 52 | }) 53 | 54 | }).catch((error) => { 55 | console.log('error ', error); 56 | setApiState({ 57 | success: false, 58 | error: true, 59 | errors: error.errors 60 | }) 61 | 62 | }) 63 | } 64 | 65 | const { 66 | success, 67 | errors 68 | } = apiState; 69 | 70 | return ( 71 |
72 |
73 |

Resend Activation Link

74 |
75 |
76 | { 77 | success && 78 | 79 |
Activation Link has been sent
80 |
81 | 82 | } 83 | { 84 | errors && 85 | } 86 | 87 | Email address 88 | 94 | 95 | This account should already exist in our database. 96 | 97 | 98 | 99 | 106 | 107 | 108 |
109 |
110 |
111 |
Already have an account?
112 |
Login
113 |
114 |
115 |
Don't have an account?
116 |
Register
117 |
118 | 119 |
120 |
121 |
122 | ) 123 | } 124 | 125 | -------------------------------------------------------------------------------- /client2/src/account/ResetPassword.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import queryString from 'query-string'; 3 | import Form from 'react-bootstrap/Form'; 4 | import Button from 'react-bootstrap/Button'; 5 | import { Link } from 'react-router-dom'; 6 | import Alert from 'react-bootstrap/Alert'; 7 | import Errors from '../common/Errors'; 8 | import './account.scss'; 9 | 10 | export default function ResetPassword(props) { 11 | const [password, setPassword] = useState(''); 12 | const [resetPasswordToken, setResetPasswordToken] = useState(''); 13 | const [apiState, setApiState] = useState({ 14 | success: false, 15 | error: false, 16 | errors: [] 17 | }) 18 | 19 | const handlePasswordChange = (event) => { 20 | setPassword(event.target.value) 21 | } 22 | 23 | useEffect(() => { 24 | // Get token from URL 25 | const queryStringParams = queryString.parse(props.location.search) || {}; 26 | const token = queryStringParams.token; 27 | 28 | if (!token) { 29 | setApiState({ 30 | success: false, 31 | error: true, 32 | errors: [{ 33 | message: 'You need to generate a Reset Password Token before resetting your password' 34 | }] 35 | }) 36 | 37 | 38 | } 39 | 40 | setResetPasswordToken(token); 41 | }, []); 42 | 43 | const handleSubmitClick = () => { 44 | setApiState({ 45 | success: false, 46 | error: false, 47 | errors: null 48 | }) 49 | 50 | // Submit the email and password to the server 51 | const url = '/api/reset-password'; 52 | 53 | fetch(url, { 54 | headers: { 55 | 'Accept': 'application/json', 56 | 'Content-Type': 'application/json' 57 | }, 58 | credentials: 'same-origin', 59 | method: 'POST', 60 | body: JSON.stringify({ 61 | resetPasswordToken: resetPasswordToken, 62 | password: password 63 | }) 64 | }).then((response) => { 65 | if (!response.ok) { 66 | return response.json().then(err => { throw err }) 67 | } 68 | return response.json(); 69 | }).then((results) => { 70 | console.log('results ', results); 71 | setApiState({ 72 | success: true, 73 | error: false, 74 | errors: null 75 | }) 76 | 77 | }).catch((error) => { 78 | console.log('error ', error); 79 | setApiState({ 80 | success: false, 81 | error: true, 82 | errors: error.errors 83 | }) 84 | }) 85 | 86 | } 87 | 88 | const { 89 | success, 90 | errors 91 | } = apiState; 92 | 93 | return ( 94 |
95 |
96 |

Reset Password

97 |
98 |
99 | { 100 | success && 101 | 102 |
Your password has been updated. Please proceed to the login page to Sign In
103 |
Login
104 |
105 | } 106 | { 107 | errors && 108 | } 109 | 110 | Password 111 | 117 | 118 | Please enter your new password 119 | 120 | 121 | 122 | 129 | 130 | 131 |
132 |
133 |
134 |
Try Login again?
135 |
Login
136 |
137 | 138 |
139 |
140 |
141 | ); 142 | 143 | } 144 | -------------------------------------------------------------------------------- /client2/src/account/ResetPasswordLink.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { Link } from 'react-router-dom'; 5 | import Alert from 'react-bootstrap/Alert'; 6 | import Errors from '../common/Errors'; 7 | import './account.scss'; 8 | 9 | export default function ResetPasswordLink(props) { 10 | const [email, setEmail] = useState(''); 11 | const [apiState, setApiState] = useState({ 12 | success: false, 13 | error: false, 14 | errors: [] 15 | }) 16 | 17 | const handleEmailChange = (event) => { 18 | setEmail(event.target.value); 19 | } 20 | 21 | const handleSubmitClick = () => { 22 | setApiState({ 23 | success: false, 24 | error: false, 25 | errors: null 26 | }) 27 | 28 | // Submit the email and password to the server 29 | const url = '/api/reset-password-link'; 30 | 31 | fetch(url, { 32 | headers: { 33 | 'Accept': 'application/json', 34 | 'Content-Type': 'application/json' 35 | }, 36 | credentials: 'same-origin', 37 | method: 'POST', 38 | body: JSON.stringify({ 39 | email 40 | }) 41 | }).then((response) => { 42 | if (!response.ok) { 43 | return response.json().then(err => { throw err }) 44 | } 45 | return response.json(); 46 | }).then((results) => { 47 | console.log('results ', results); 48 | setApiState({ 49 | success: true, 50 | error: false, 51 | errors: null 52 | }) 53 | 54 | }).catch((error) => { 55 | console.log('error ', error); 56 | setApiState({ 57 | success: false, 58 | error: true, 59 | errors: error.errors 60 | }) 61 | }) 62 | } 63 | const { 64 | success, 65 | errors 66 | } = apiState; 67 | 68 | return ( 69 |
70 |
71 |

Reset Password Link

72 |
73 |
74 | { 75 | success && 76 | 77 |
Please check your email for the link to reset your password
78 |
79 | 80 | } 81 | { 82 | errors && 83 | } 84 | 85 | Email address 86 | 92 | 93 | This account should already exist in our database. 94 | 95 | 96 | 97 | 104 | 105 | 106 |
107 |
108 |
Try Login again?
109 |
Login
110 |
111 | 112 |
113 |
114 |
115 |
116 | ); 117 | 118 | 119 | } 120 | -------------------------------------------------------------------------------- /client2/src/account/account.scss: -------------------------------------------------------------------------------- 1 | .form-container { 2 | max-width: 340px; 3 | margin: 0 auto; 4 | background: white; 5 | padding: 16px; 6 | } 7 | 8 | .actions-container { 9 | margin-top: 24px; 10 | } -------------------------------------------------------------------------------- /client2/src/account/actionsAuth.js: -------------------------------------------------------------------------------- 1 | export const AUTH_BUSY = 'AUTH_BUSY'; 2 | export const AUTH_RETRIEVEUSER_SUCCESS = 'AUTH_RETRIEVEUSER_SUCCESS'; 3 | export const AUTH_RETRIEVEUSER_ERROR = 'AUTH_RETRIEVEUSER_ERROR'; 4 | 5 | export function fetchLoggedInUser() { 6 | return function(dispatch, getState) { 7 | dispatch({ 8 | type: AUTH_BUSY 9 | }); 10 | const url = '/api/logged-in-user'; 11 | 12 | fetch(url, { 13 | headers: { 14 | 'Accept': 'application/json', 15 | 'Content-Type': 'application/json' 16 | }, 17 | credentials: 'same-origin', 18 | method: 'GET' 19 | }).then((response) => { 20 | if (!response.ok) { 21 | return response.json().then(err => { throw err }) 22 | } 23 | return response.json(); 24 | }).then((results) => { 25 | console.log('results ', results); 26 | const user = results.user; 27 | 28 | dispatch({ 29 | type: AUTH_RETRIEVEUSER_SUCCESS, 30 | payload: user 31 | }); 32 | }).catch((error) => { 33 | console.log('error ', error); 34 | dispatch({ 35 | type: AUTH_RETRIEVEUSER_ERROR, 36 | payload: null 37 | }); 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client2/src/account/reducerAuth.js: -------------------------------------------------------------------------------- 1 | import _isEmpty from 'lodash/isEmpty'; 2 | 3 | import { 4 | AUTH_BUSY, 5 | AUTH_RETRIEVEUSER_SUCCESS, 6 | AUTH_RETRIEVEUSER_ERROR 7 | } from './actionsAuth'; 8 | 9 | export default function auth(state = {}, action) { 10 | switch(action.type) { 11 | case AUTH_BUSY: { 12 | return { 13 | ...state, 14 | busy: true 15 | } 16 | } 17 | 18 | case AUTH_RETRIEVEUSER_ERROR: { 19 | return { 20 | ...state, 21 | busy: false, 22 | error: null, 23 | authenticated: false, 24 | user: null 25 | } 26 | } 27 | case AUTH_RETRIEVEUSER_SUCCESS: { 28 | const { payload = {} } = action; 29 | return { 30 | ...state, 31 | busy: false, 32 | error: null, 33 | authenticated: !_isEmpty(payload), 34 | user: payload 35 | } 36 | } 37 | default: return state; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client2/src/common/Errors.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Alert from 'react-bootstrap/Alert'; 3 | import _map from 'lodash/map'; 4 | 5 | export default class Errors extends React.Component { 6 | render() { 7 | const { 8 | errors 9 | } = this.props; 10 | 11 | const errorMessages = _map(errors, 'message'); 12 | 13 | return ( 14 |
15 | { 16 | _map(errorMessages, (error, index) => { 17 | return ( 18 | 19 | {error} 20 | 21 | ) 22 | }) 23 | } 24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client2/src/header/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Navbar from 'react-bootstrap/Navbar'; 3 | import Nav from 'react-bootstrap/Nav'; 4 | 5 | export class Header extends Component { 6 | render() { 7 | return ( 8 |
9 | <> 10 | 11 | SAASIFY 12 | 17 | 18 | 19 |
20 | ); 21 | } 22 | } 23 | 24 | export default Header; 25 | -------------------------------------------------------------------------------- /client2/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /client2/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | import store from './store/configureStore'; 5 | import './index.css'; 6 | import App from './App'; 7 | import * as serviceWorker from './serviceWorker'; 8 | import { 9 | BrowserRouter as Router 10 | } from 'react-router-dom'; 11 | 12 | const root = ReactDOM.createRoot(document.getElementById('root')); 13 | 14 | root.render( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | // If you want your app to work offline and load faster, you can change 23 | // unregister() to register() below. Note this comes with some pitfalls. 24 | // Learn more about service workers: https://bit.ly/CRA-PWA 25 | serviceWorker.unregister(); 26 | -------------------------------------------------------------------------------- /client2/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client2/src/payments/CreateSubscription.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import StripeProviderComponent from './StripeProviderComponent'; 5 | import Card from 'react-bootstrap/Card'; 6 | import _get from 'lodash/get'; 7 | 8 | export default class CreateSubscription extends React.Component { 9 | handlePlanChange = (event) => { 10 | this.props.setPlan(event.target.value) 11 | } 12 | 13 | createSubscription = () => { 14 | this.props.onPaymentMethodCreated(); 15 | } 16 | 17 | render() { 18 | const { 19 | user 20 | } = this.props; 21 | 22 | const hasSubscription = _get(user, 'stripeDetails.subscription.status', '') === 'active'; 23 | 24 | return ( 25 | 26 | 27 | { 28 | hasSubscription ? 29 |

Update your subscription

: 30 |

Subscribe to a plan

31 | } 32 |
33 |
34 | 35 | Select your plan 36 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | { 49 | !this.props.card && 50 | 55 | } 56 | 57 | { 58 | this.props.card && 59 | 66 | } 67 | 68 | 69 | 70 |
71 |
72 |
73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /client2/src/payments/CreditCardForm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Use the CSS tab above to style your Element's container. 3 | */ 4 | import React from 'react'; 5 | import { CardElement, injectStripe } from 'react-stripe-elements'; 6 | import Button from 'react-bootstrap/Button'; 7 | 8 | const style = { 9 | base: { 10 | color: "#32325d", 11 | fontFamily: '"Helvetica Neue", Helvetica, sans-serif', 12 | fontSmoothing: "antialiased", 13 | fontSize: "16px", 14 | "::placeholder": { 15 | color: "#aab7c4" 16 | } 17 | }, 18 | invalid: { 19 | color: "#fa755a", 20 | iconColor: "#fa755a" 21 | } 22 | }; 23 | 24 | class CardSection extends React.Component { 25 | handleSubmitClick = async (event) => { 26 | 27 | const cardElement = this.props.elements.getElement('card'); 28 | 29 | const { paymentMethod, error } = await this.props.stripe.createPaymentMethod({ 30 | type: 'card', 31 | card: cardElement, 32 | billing_details: { 33 | email: this.props.user.email, 34 | }, 35 | }); 36 | 37 | console.log('paymentMethod', paymentMethod); 38 | console.log('error', error); 39 | 40 | if (!error) { 41 | this.props.onPaymentMethodCreated(paymentMethod); 42 | } 43 | } 44 | 45 | render() { 46 | return ( 47 |
48 |
49 |
50 | Card details 51 | 52 |
53 | 54 | 61 |
62 |
63 | ); 64 | } 65 | }; 66 | 67 | export default injectStripe(CardSection); 68 | -------------------------------------------------------------------------------- /client2/src/payments/CurrentCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _get from 'lodash/get'; 3 | import Card from 'react-bootstrap/Card'; 4 | 5 | export default class CurrentCard extends React.Component { 6 | render() { 7 | const { 8 | card 9 | } = this.props; 10 | 11 | const brand = _get(card, 'brand'); 12 | const exp_month = _get(card, 'exp_month'); 13 | const exp_year = _get(card, 'exp_year'); 14 | const last4 = _get(card, 'last4'); 15 | 16 | if (!card) { 17 | return ( 18 | 19 | 20 |

Card details

21 |
You do not have any saved cards.
22 |
23 |
24 | ) 25 | } 26 | 27 | return ( 28 | 29 | 30 |

Card details

31 |
32 |
Brand
33 |

{brand}

34 |
Expiry Month
35 |

{exp_month}

36 |
Expiry Year
37 |

{exp_year}

38 |
Last 4
39 |

{last4}

40 |
41 |
42 |
43 | 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client2/src/payments/CurrentSubscription.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from 'react-bootstrap/Card'; 3 | import Button from 'react-bootstrap/Button'; 4 | 5 | export default class CurrentSubscription extends React.Component { 6 | render() { 7 | const { 8 | subscription 9 | } = this.props; 10 | if (!subscription) { 11 | return ( 12 | 13 | 14 |

Subscription details

15 |
You do not have any subscriptions yet.
16 |
17 |
18 | ) 19 | } 20 | 21 | const nickname = subscription.plan.nickname; 22 | const amount = subscription.plan.amount; 23 | const interval = subscription.plan.interval; 24 | const status = subscription.status; 25 | 26 | 27 | return ( 28 | 29 | 30 |

Subscription details

31 |
32 |
Status
33 |

{status}

34 |
You are subscribed to
35 |

{nickname}

36 |
We will charge you
37 |

{amount}

38 |
Interval
39 |

{interval}

40 |
41 | 49 |
50 |
51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client2/src/payments/Payments.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CurrentSubscription from './CurrentSubscription'; 3 | import CurrentCard from './CurrentCard'; 4 | import CreateSubscription from './CreateSubscription'; 5 | import UpdateCard from './UpdateCard'; 6 | import _get from 'lodash/get'; 7 | 8 | const stripe = Stripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY); // eslint-disable-line 9 | 10 | export default class Payments extends React.Component { 11 | state = { 12 | subscription: null, 13 | card: null, 14 | plan: '' 15 | } 16 | componentDidMount() { 17 | this.getPaymentDetails(); 18 | } 19 | 20 | getPaymentDetails = () => { 21 | this.getSubscription(); 22 | this.getCard(); 23 | } 24 | 25 | setPlan = (planId) => { 26 | this.setState({ 27 | plan: planId 28 | }) 29 | } 30 | 31 | createSubscription = (paymentMethod) => { 32 | if (!this.state.plan) { 33 | // Show error to user 34 | return; 35 | } 36 | 37 | const planId = this.state.plan; 38 | 39 | const url = '/api/payments/subscriptions'; 40 | 41 | fetch(url, { 42 | headers: { 43 | 'Accept': 'application/json', 44 | 'Content-Type': 'application/json' 45 | }, 46 | credentials: 'same-origin', 47 | method: 'POST', 48 | body: JSON.stringify({ 49 | planId: planId, 50 | paymentMethod 51 | }) 52 | }).then((response) => { 53 | if (!response.ok) { 54 | return response.json().then(err => { throw err }) 55 | } 56 | return response.json(); 57 | }).then((results) => { 58 | console.log('results ', results); 59 | const subscription = _get(results, 'subscription'); 60 | const paymentIntentStatus = _get(subscription, 'latest_invoice.payment_intent.status', ''); 61 | const client_secret = _get(subscription, 'latest_invoice.payment_intent.client_secret', ''); 62 | if (paymentIntentStatus === 'requires_action' || paymentIntentStatus === 'requires_payment_method') { 63 | stripe.confirmCardPayment(client_secret).then(function(result) { 64 | console.log('confirmCardPayment result', result); 65 | if (result.error) { 66 | // Display error message in your UI. 67 | // The card was declined (i.e. insufficient funds, card has expired, etc) 68 | alert(result.error); 69 | 70 | } else { 71 | // Show a success message to your customer 72 | alert('You have successfully confirmed the payment'); 73 | window.location.href = '/payments'; 74 | } 75 | 76 | 77 | }); 78 | } 79 | 80 | // this.getPaymentDetails(); 81 | 82 | }).catch((error) => { 83 | console.log('error ', error); 84 | // this.getPaymentDetails(); 85 | window.location.href = '/payments'; 86 | }) 87 | } 88 | 89 | updateCard = (paymentMethod) => { 90 | const url = '/api/payments/cards'; 91 | 92 | fetch(url, { 93 | headers: { 94 | 'Accept': 'application/json', 95 | 'Content-Type': 'application/json' 96 | }, 97 | credentials: 'same-origin', 98 | method: 'POST', 99 | body: JSON.stringify({ 100 | paymentMethod 101 | }) 102 | }).then((response) => { 103 | if (!response.ok) { 104 | return response.json().then(err => { throw err }) 105 | } 106 | return response.json(); 107 | }).then((results) => { 108 | console.log('results ', results); 109 | // this.getPaymentDetails(); 110 | window.location.href = '/payments'; 111 | 112 | }).catch((error) => { 113 | console.log('error ', error); 114 | // this.getPaymentDetails(); 115 | window.location.href = '/payments'; 116 | }) 117 | } 118 | 119 | deleteSubscription = () => { 120 | const url = '/api/payments/subscriptions'; 121 | 122 | fetch(url, { 123 | headers: { 124 | 'Accept': 'application/json', 125 | 'Content-Type': 'application/json' 126 | }, 127 | credentials: 'same-origin', 128 | method: 'DELETE' 129 | }).then((response) => { 130 | if (!response.ok) { 131 | return response.json().then(err => { throw err }) 132 | } 133 | return response.json(); 134 | }).then((results) => { 135 | console.log('results ', results); 136 | // this.getPaymentDetails(); 137 | window.location.href = '/payments'; 138 | 139 | }).catch((error) => { 140 | console.log('error ', error); 141 | // this.getPaymentDetails(); 142 | window.location.href = '/payments'; 143 | }) 144 | } 145 | 146 | getSubscription = (paymentMethod) => { 147 | const url = '/api/payments/subscriptions'; 148 | 149 | fetch(url, { 150 | headers: { 151 | 'Accept': 'application/json', 152 | 'Content-Type': 'application/json' 153 | }, 154 | credentials: 'same-origin', 155 | method: 'GET' 156 | }).then((response) => { 157 | if (!response.ok) { 158 | return response.json().then(err => { throw err }) 159 | } 160 | return response.json(); 161 | }).then((results) => { 162 | console.log('results ', results); 163 | this.setState({ 164 | subscription: results.subscription 165 | }) 166 | 167 | }).catch((error) => { 168 | console.log('error ', error); 169 | }) 170 | } 171 | 172 | getCard = () => { 173 | const url = '/api/payments/cards'; 174 | 175 | fetch(url, { 176 | headers: { 177 | 'Accept': 'application/json', 178 | 'Content-Type': 'application/json' 179 | }, 180 | credentials: 'same-origin', 181 | method: 'GET' 182 | }).then((response) => { 183 | if (!response.ok) { 184 | return response.json().then(err => { throw err }) 185 | } 186 | return response.json(); 187 | }).then((results) => { 188 | console.log('results ', results); 189 | this.setState({ 190 | card: results.card 191 | }) 192 | 193 | }).catch((error) => { 194 | console.log('error ', error); 195 | }) 196 | } 197 | 198 | render() { 199 | const customer = _get(this.props.user, 'stripeDetails.customer'); 200 | 201 | return ( 202 |
203 |
204 |

Payments

205 | 206 |
207 |
208 | 212 |
213 |
214 | 215 |
216 |
217 | 218 | 219 | 220 | { 221 | customer && 222 | 227 | } 228 | 229 | 237 |
238 |
239 | ); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /client2/src/payments/StripeProviderComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StripeProvider, Elements } from 'react-stripe-elements'; 3 | import CreditCardForm from './CreditCardForm'; 4 | 5 | export default class StripeProviderComponent extends React.Component { 6 | render() { 7 | return ( 8 |
9 | 10 | 11 | 16 | 17 | 18 |
19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client2/src/payments/UpdateCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from 'react-bootstrap/Card'; 3 | import StripeProviderComponent from './StripeProviderComponent'; 4 | 5 | export default class UpdateCard extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 |

Update Card

11 |
12 | 17 |
18 |
19 |
20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client2/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /client2/src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, compose, createStore } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { createLogger } from 'redux-logger'; 4 | import rootReducer from './rootReducer'; 5 | 6 | let store; 7 | 8 | const configureStore = (initialState) => { 9 | const middleware = []; 10 | // const enhancers = []; 11 | 12 | // THUNK 13 | middleware.push(thunk); 14 | 15 | // Logging Middleware 16 | const logger = createLogger({ 17 | level: 'info', 18 | collapsed: true 19 | }); 20 | middleware.push(logger); 21 | 22 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 23 | 24 | store = createStore(rootReducer, initialState, composeEnhancers( 25 | applyMiddleware(...middleware) 26 | )); 27 | 28 | } 29 | 30 | 31 | if (!store) { 32 | configureStore(); 33 | } 34 | 35 | export default store; 36 | -------------------------------------------------------------------------------- /client2/src/store/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import auth from '../account/reducerAuth'; 3 | 4 | const rootReducer = combineReducers({ 5 | auth 6 | }); 7 | 8 | export default rootReducer; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas", 3 | "version": "1.0.0", 4 | "description": "SAASiFy is a sample SAAS app built with NodeJS and ReactJS. It is primarily built for the Udemy course [SaasiFy - Build a complete SAAS App this weekend](https://www.udemy.com/course/2786970).", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "cd server && npm start", 8 | "heroku-postbuild": "cd client/ && npm install && npm run build && cd ../server && npm install" 9 | }, 10 | "cacheDirectories": [ 11 | "node_modules", 12 | "client/node_modules", 13 | "server/node_modules" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/saas-developer/saas-starter.git" 18 | }, 19 | "author": "", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/saas-developer/saas-starter/issues" 23 | }, 24 | "homepage": "https://github.com/saas-developer/saas-starter#readme", 25 | "engines" : { 26 | "node" : "12.16.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/.env-sample: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://0.0.0.0/saas 2 | JWT_SECRET=very-long-secret-abcdef 3 | APP_PORT=3001 4 | APP_EMAIL=youremail@yourdomain.com 5 | MAILGUN_DOMAIN=sandbox-abcd.mailgun.org 6 | MAILGUN_API_KEY=key-abcd 7 | BASE_URL=http://localhost:3000 8 | STRIPE_PUBLISHABLE_KEY=pk_test_abcd 9 | STRIPE_SECRET_KEY=sk_test_abcd 10 | STRIPE_WEBHOOK_SIGNING_SECRET=whsec_abcd 11 | -------------------------------------------------------------------------------- /server/controllers/authController.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/User'); 2 | const passport = require('passport'); 3 | const jwt = require('jsonwebtoken'); 4 | const uuidv1 = require('uuid/v1'); 5 | const { 6 | sendAccountActivationEmail, 7 | resendActivationLink, 8 | sendResetPasswordLinkEmail 9 | } = require('../lib/EmailManager'); 10 | 11 | exports.getLoggedInUser = async (req, res, next) => { 12 | const user = User.toClientObject(req.user); 13 | 14 | res.send({ 15 | user 16 | }) 17 | } 18 | 19 | exports.register = async (req, res, next) => { 20 | const { 21 | email, 22 | password 23 | } = req.body; 24 | 25 | // Validate the input fields 26 | const validationErrors = []; 27 | 28 | if (!email) { 29 | validationErrors.push({ 30 | code: 'VALIDATION_ERROR', 31 | field: 'email', 32 | message: ' You must provide an email address' 33 | }); 34 | } 35 | 36 | const isEmailValid = email && validateEmail(email); 37 | if (email && !isEmailValid) { 38 | validationErrors.push({ 39 | code: 'VALIDATION_ERROR', 40 | field: 'email', 41 | message: 'Email is not valid' 42 | }); 43 | } 44 | 45 | 46 | if (!password) { 47 | validationErrors.push({ 48 | code: 'VALIDATION_ERROR', 49 | field: 'password', 50 | message: ' You must provide a password' 51 | }); 52 | } 53 | 54 | if (validationErrors.length) { 55 | const errorObject = { 56 | error: true, 57 | errors: validationErrors 58 | }; 59 | 60 | res.status(422).send(errorObject); 61 | 62 | return; 63 | } 64 | 65 | // Save this info to DB 66 | 67 | try { 68 | const existingUser = await User.findOne({ email }); 69 | if (existingUser) { 70 | const errorObject = { 71 | error: true, 72 | errors: [{ 73 | code: 'VALIDATION_ERROR', 74 | field: 'Email', 75 | message: 'Email already exists' 76 | }] 77 | }; 78 | 79 | res.status(422).send(errorObject); 80 | 81 | return; 82 | } 83 | 84 | 85 | let user = new User({ 86 | email, 87 | password, 88 | activated: false, 89 | activationToken: uuidv1(), 90 | activationTokenSentAt: Date.now() 91 | }); 92 | 93 | const savedUser = await user.save(); 94 | 95 | console.log('savedUser ', savedUser); 96 | 97 | // Here -> We will send the email 98 | sendAccountActivationEmail(savedUser); 99 | 100 | res.status(200).send({ 101 | user: User.toClientObject(savedUser) 102 | }) 103 | 104 | } catch (e) { 105 | console.log('e ', e); 106 | } 107 | 108 | 109 | 110 | } 111 | 112 | exports.login = async (req, res, next) => { 113 | const { 114 | email, 115 | password 116 | } = req.body; 117 | // Validate the input fields 118 | const validationErrors = []; 119 | 120 | if (!email) { 121 | validationErrors.push({ 122 | code: 'VALIDATION_ERROR', 123 | field: 'email', 124 | message: ' You must provide an email address' 125 | }); 126 | } 127 | 128 | const isEmailValid = email && validateEmail(email); 129 | if (email && !isEmailValid) { 130 | validationErrors.push({ 131 | code: 'VALIDATION_ERROR', 132 | field: 'email', 133 | message: 'Email is not valid' 134 | }); 135 | } 136 | 137 | 138 | if (!password) { 139 | validationErrors.push({ 140 | code: 'VALIDATION_ERROR', 141 | field: 'password', 142 | message: ' You must provide a password' 143 | }); 144 | } 145 | 146 | if (validationErrors.length) { 147 | const errorObject = { 148 | error: true, 149 | errors: validationErrors 150 | }; 151 | 152 | res.status(422).send(errorObject); 153 | 154 | return; 155 | } 156 | 157 | passport.authenticate('local', (err, user, info) => { 158 | if (err) { 159 | return next(err); 160 | } 161 | 162 | if (!user) { 163 | res.status(401).send(info); 164 | return; 165 | } 166 | const errorObject = { 167 | error: true, 168 | errors: [{ 169 | code: 'GLOBAL_ERROR', 170 | message: 'Account is not activated.' 171 | }] 172 | }; 173 | 174 | if (!user.activated) { 175 | res.status(403).send(errorObject) 176 | return; 177 | } 178 | 179 | const userObject = user.toObject(); 180 | const tokenObject = { 181 | _id: userObject._id 182 | } 183 | 184 | const jwtToken = jwt.sign(tokenObject, process.env.JWT_SECRET, { 185 | expiresIn: 86400 // seconds in a day 186 | }) 187 | 188 | res.status(200).send({ 189 | token: jwtToken, 190 | user: User.toClientObject(user) 191 | }); 192 | return; 193 | 194 | 195 | 196 | })(req, res, next); 197 | 198 | } 199 | 200 | exports.accountActivate = async (req, res, next) => { 201 | const { 202 | activationToken 203 | } = req.body; 204 | 205 | if (!activationToken) { 206 | const errorObject = { 207 | error: true, 208 | errors: [{ 209 | code: 'VALIDATION_ERROR', 210 | message: 'Invalid Activation Token. Perhaps you requested a new token?' 211 | }] 212 | }; 213 | 214 | res.status(422).send(errorObject); 215 | 216 | return; 217 | } 218 | 219 | try { 220 | const user = await User.findOne({ 221 | activationToken 222 | }); 223 | 224 | if (!user) { 225 | const errorObject = { 226 | error: true, 227 | errors: [{ 228 | code: 'VALIDATION_ERROR', 229 | message: 'Invalid Activation Token. Perhaps you requested a new token?' 230 | }] 231 | }; 232 | 233 | res.status(422).send(errorObject); 234 | 235 | return; 236 | } 237 | 238 | // We found a user 239 | user.activated = true; 240 | user.activationToken = undefined; 241 | user.activatedAt = Date.now(); 242 | 243 | const savedUser = await user.save(); 244 | 245 | return res.send({ 246 | message: 'Your account has been activated. Please proceed to the Login page to Sign In' 247 | }); 248 | 249 | 250 | } catch (e) { 251 | console.log('e ', e); 252 | res.status(500).send({ 253 | error: true 254 | }) 255 | } 256 | 257 | } 258 | 259 | exports.resendActivationLink = async (req, res, next) => { 260 | const { 261 | email 262 | } = req.body; 263 | 264 | if (!email) { 265 | const errorObject = { 266 | error: true, 267 | errors: [{ 268 | code: 'VALIDATION_ERROR', 269 | message: 'Please specify the email account that needs activation' 270 | }] 271 | }; 272 | 273 | res.status(422).send(errorObject); 274 | 275 | return; 276 | } 277 | 278 | try { 279 | const user = await User.findOne({ 280 | email 281 | }); 282 | 283 | if (user && !user.activated) { 284 | user.activationToken = uuidv1(); 285 | user.activationTokenSentAt = Date.now(); 286 | 287 | await user.save(); 288 | 289 | // Send activation email 290 | //... 291 | await resendActivationLink(user); 292 | } 293 | 294 | 295 | return res.send({ 296 | message: 'Activation Link has been sent' 297 | }) 298 | 299 | } catch (e) { 300 | console.log('e ', e); 301 | res.status(500).send({ 302 | error: true 303 | }) 304 | } 305 | } 306 | 307 | exports.resetPasswordLink = async ( req, res, next) => { 308 | const { 309 | email 310 | } = req.body; 311 | 312 | if (!email) { 313 | const errorObject = { 314 | error: true, 315 | errors: [{ 316 | code: 'VALIDATION_ERROR', 317 | message: 'Please specify the email account that needs password reset' 318 | }] 319 | }; 320 | 321 | res.status(422).send(errorObject); 322 | 323 | return; 324 | } 325 | 326 | try { 327 | const user = await User.findOne({ 328 | email 329 | }); 330 | 331 | if (user) { 332 | user.resetPasswordToken = uuidv1(); 333 | user.resetPasswordTokenSentAt = Date.now(); 334 | 335 | const savedUser = user.save(); 336 | 337 | // Send email now ... 338 | sendResetPasswordLinkEmail(user); 339 | 340 | } 341 | 342 | return res.send({ 343 | message: 'Reset Password Link has been sent' 344 | }) 345 | 346 | } catch (e) { 347 | console.log('e ', e); 348 | res.status(500).send({ 349 | error: true 350 | }) 351 | } 352 | 353 | } 354 | 355 | exports.resetPassword = async (req, res, next) => { 356 | const { 357 | resetPasswordToken, 358 | password 359 | } = req.body; 360 | 361 | const validationErrors = []; 362 | 363 | if (!password || !resetPasswordToken) { 364 | validationErrors.push({ 365 | code: 'VALIDATION_ERROR', 366 | field: '', 367 | message: 'Sorry, we could not update your password' 368 | }) 369 | } 370 | 371 | if (validationErrors.length) { 372 | const errorObject = { 373 | error: true, 374 | errors: validationErrors 375 | }; 376 | 377 | res.status(422).send(errorObject); 378 | return res.status(422).send(errorObject); 379 | } 380 | 381 | try { 382 | const user = await User.findOne({ 383 | resetPasswordToken 384 | }); 385 | 386 | if (!user) { 387 | const errorObject = { 388 | error: true, 389 | errors: [{ 390 | code: 'GLOBAL_ERROR', 391 | message: 'Sorry, we could not update your password' 392 | }] 393 | }; 394 | 395 | return res.status(422).send(errorObject); 396 | } 397 | 398 | if (user) { 399 | user.password = password; 400 | user.resetPasswordToken = undefined; 401 | 402 | const savedUser = await user.save(); 403 | } 404 | 405 | res.status(200).send({ 406 | message: 'Your password has been successfully updated. Please go to the Login Page to Sign In again' 407 | }); 408 | 409 | 410 | } catch (e) { 411 | console.log('e ', e); 412 | res.status(500).send({ 413 | error: true 414 | }) 415 | } 416 | 417 | 418 | 419 | } 420 | 421 | exports.testAuth = async (req, res, next) => { 422 | console.log('req.user ', req.user); 423 | 424 | res.send({ 425 | isLoggedIn: req.user ? true : false 426 | }) 427 | } 428 | 429 | 430 | function validateEmail(email) { 431 | var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 432 | return re.test(String(email).toLowerCase()); 433 | } 434 | -------------------------------------------------------------------------------- /server/controllers/paymentsController.js: -------------------------------------------------------------------------------- 1 | const { 2 | createCustomer, 3 | createSubscription, 4 | updateSubscription, 5 | retrievePaymentMethod, 6 | deleteSubscription, 7 | attachPaymentMethodToCustomer, 8 | updateCustomer 9 | } = require('../lib/StripeManager'); 10 | const User = require('../models/User'); 11 | const _ = require('lodash'); 12 | const stripe = require('stripe'); 13 | 14 | exports.createSubscription = async (req, res, next) => { 15 | const { 16 | planId = 'stripe-plan-basic', 17 | paymentMethod 18 | } = req.body; 19 | 20 | let user = req.user; 21 | 22 | 23 | const email = user.email; 24 | const userId = user.id; 25 | let paymentMethodId; 26 | 27 | if (!paymentMethod) { 28 | // Get paymentMethod from user object 29 | paymentMethodId = _.get(user, 'stripeDetails.customer.invoice_settings.default_payment_method'); 30 | } else { 31 | paymentMethodId = paymentMethod.id; 32 | } 33 | 34 | if (!paymentMethodId) { 35 | res.status(422).send({ 36 | message: 'No payment method available for user' 37 | }); 38 | return; 39 | } 40 | 41 | // Step 1: Get stripe customer from database 42 | let stripeCustomer = user.stripeDetails && user.stripeDetails.customer; 43 | // If there is no stripe customer 44 | if (!stripeCustomer) { 45 | console.log('Creating Customer'); 46 | // Then create a new record 47 | stripeCustomer = await createCustomer(paymentMethodId, email); 48 | // Save new customer to the database 49 | user = await User.saveStripeCustomer(userId, stripeCustomer); 50 | } else { 51 | console.log('Using Existing Customer'); 52 | } 53 | 54 | 55 | // Step 1: Get stripe Subscription from database 56 | let stripeSubscription = user.stripeDetails && user.stripeDetails.subscription; 57 | // If there is no stripe Subscription 58 | if (!stripeSubscription) { 59 | console.log('Creating Subscription'); 60 | stripeSubscription = await createSubscription(stripeCustomer.id, planId); 61 | user = await User.saveStripeSubscription(userId, stripeSubscription); 62 | } else { 63 | console.log('Using Existing Subscription'); 64 | const subscriptionId = stripeSubscription.id; 65 | // Update subscription with new plan 66 | stripeSubscription = await updateSubscription(subscriptionId, planId); 67 | user = await User.saveStripeSubscription(userId, stripeSubscription); 68 | } 69 | 70 | 71 | 72 | 73 | console.log('Created Subscription'); 74 | console.log(stripeSubscription); 75 | 76 | res.send({ 77 | subscription: stripeSubscription 78 | }) 79 | } 80 | 81 | exports.getSubscription = async (req, res, next) => { 82 | const user = req.user; 83 | 84 | const subscription = user.stripeDetails && user.stripeDetails.subscription; 85 | 86 | res.send({ 87 | subscription 88 | }) 89 | } 90 | 91 | 92 | exports.getCard = async (req, res, next) => { 93 | const user = req.user; 94 | 95 | const stripeDetails = user.stripeDetails; 96 | const defaultPaymentMethod = _.get(stripeDetails, 'customer.invoice_settings.default_payment_method'); 97 | 98 | if (defaultPaymentMethod) { 99 | const paymentMethod = await retrievePaymentMethod(defaultPaymentMethod); 100 | res.send({ 101 | card: _.get(paymentMethod, 'card') 102 | }); 103 | return; 104 | } 105 | 106 | res.send({ 107 | card: null 108 | }) 109 | } 110 | 111 | exports.updateCard = async (req, res, next) => { 112 | const { 113 | paymentMethod 114 | } = req.body; 115 | 116 | if (!paymentMethod) { 117 | res.status(422).send({ 118 | message: 'Payment Method is required' 119 | }); 120 | return; 121 | } 122 | 123 | let user = req.user; 124 | let paymentMethodId = paymentMethod.id; 125 | let customerId = _.get(user, 'stripeDetails.customer.id'); 126 | let updatedPaymethod; 127 | let updatedCustomer; 128 | 129 | try { 130 | // Step 1: Attach payment method to customer 131 | updatedPaymethod = await attachPaymentMethodToCustomer(paymentMethodId, customerId); 132 | // Step 2: Update Customer and set payment method as default payment method 133 | updatedCustomer = await updateCustomer(customerId, { 134 | invoice_settings: { 135 | default_payment_method: paymentMethodId 136 | } 137 | }) 138 | // Step 3: Update our database with new customer information 139 | user = await User.saveStripeCustomer(user.id, updatedCustomer); 140 | 141 | } catch (e) { 142 | console.log('e', e); 143 | res.status(500).send({ 144 | code: 'GLOBAL_ERROR', 145 | field: '', 146 | message: 'An occurred while updating your card' 147 | }); 148 | return; 149 | } 150 | 151 | res.send({ 152 | customer: updatedCustomer 153 | }) 154 | 155 | } 156 | 157 | exports.deleteSubscription = async (req, res, next) => { 158 | let user = req.user; 159 | const subscriptionId = _.get(user, 'stripeDetails.subscription.id'); 160 | 161 | if (!subscriptionId) { 162 | res.status(422).send({ 163 | message: 'You do not have any active subscriptions' 164 | }); 165 | return; 166 | } 167 | 168 | let deletedSubscription; 169 | 170 | try { 171 | deletedSubscription = await deleteSubscription(subscriptionId); 172 | user = await User.saveStripeSubscription(user.id, null); 173 | 174 | console.log('deletedSubscription', deletedSubscription); 175 | } catch (e) { 176 | res.status(500).send({ 177 | code: 'GLOBAL_ERROR', 178 | field: '', 179 | message: 'An occurred while cancelling your subscription' 180 | }); 181 | return; 182 | } 183 | 184 | res.send({ 185 | subscription: deletedSubscription 186 | }) 187 | 188 | } 189 | 190 | exports.processStripeWebhook = async (req, res, next) => { 191 | const sig = req.headers['stripe-signature']; 192 | const endpointSecret = process.env.STRIPE_WEBHOOK_SIGNING_SECRET; 193 | 194 | let event; 195 | 196 | try { 197 | event = stripe.webhooks.constructEvent(req.rawBody, sig, endpointSecret); 198 | } 199 | catch (err) { 200 | res.status(400).send(`Webhook Error: ${err.message}`); 201 | return; 202 | } 203 | 204 | switch (event.type) { 205 | case 'customer.subscription.updated': 206 | console.log('Received customer.subscription.updated'); 207 | console.log('event', event); 208 | const subscription = event.data.object; 209 | const customer = 'cus_GiK4wRu8IyTFoK'; // subscription.customer; 210 | 211 | let user = await User.findOne({ 212 | 'stripeDetails.customer.id': customer 213 | }); 214 | 215 | if (user) { 216 | // Save subscription to the database 217 | console.log('user.toObject()', user.toObject()); 218 | user = await User.saveStripeSubscription(user._id, subscription) 219 | } 220 | 221 | break; 222 | 223 | } 224 | 225 | // Return a response to acknowledge receipt of the event 226 | res.json({ 227 | received: true 228 | }); 229 | } 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /server/db/databaseSetup.js: -------------------------------------------------------------------------------- 1 | // connect to DB 2 | 3 | const mongoose = require('mongoose'); 4 | mongoose.Promise = Promise; 5 | 6 | mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true }) 7 | .then(() => { 8 | console.log('SUCCESS: DB connection '); 9 | }) 10 | .catch((err) => { 11 | console.log('ERROR: DB connection '); 12 | console.log('err ', err); 13 | }); 14 | -------------------------------------------------------------------------------- /server/lib/EmailManager.js: -------------------------------------------------------------------------------- 1 | const mailgun = require("mailgun-js"); 2 | const DOMAIN = process.env.MAILGUN_DOMAIN; 3 | const APP_EMAIL = process.env.APP_EMAIL; 4 | 5 | exports.sendAccountActivationEmail = async (user) => { 6 | 7 | const token = user.activationToken; 8 | // Eg: http://localhost:3000/account/activate?token=e04ce280-1ca8-11ea-b82f-dbd64ede476e 9 | const accountActivationLink = process.env.BASE_URL + '/account/activate?token=' + token; 10 | console.log('accountActivationLink', accountActivationLink); 11 | 12 | // Next, send this link via mailgun to the user 13 | const mg = mailgun({ 14 | apiKey: process.env.MAILGUN_API_KEY, 15 | domain: DOMAIN 16 | }); 17 | 18 | 19 | const data = { 20 | from: APP_EMAIL, 21 | to: process.env.NODE_ENV === 'production' ? user.email : APP_EMAIL, 22 | subject: 'Account Activation', 23 | template: "account_activation", 24 | 'h:X-Mailgun-Variables': JSON.stringify({ 25 | activation_link: accountActivationLink 26 | }) 27 | }; 28 | 29 | 30 | try { 31 | const body = await mg.messages().send(data); 32 | console.log('body', body); 33 | 34 | } catch (e) { 35 | console.log('e', e); 36 | } 37 | } 38 | 39 | exports.resendActivationLink = async (user) => { 40 | return exports.sendAccountActivationEmail(user); 41 | } 42 | 43 | 44 | exports.sendResetPasswordLinkEmail = async (user) => { 45 | 46 | const token = user.resetPasswordToken; 47 | // Eg: http://localhost:3000/account/reset-password?token=e04ce280-1ca8-11ea-b82f-dbd64ede476e 48 | const resetPasswordLink = process.env.BASE_URL + '/account/reset-password?token=' + token; 49 | console.log('resetPasswordLink', resetPasswordLink); 50 | 51 | // Next, send this link via mailgun to the user 52 | const mg = mailgun({ 53 | apiKey: process.env.MAILGUN_API_KEY, 54 | domain: DOMAIN 55 | }); 56 | 57 | 58 | const data = { 59 | from: APP_EMAIL, 60 | to: process.env.NODE_ENV === 'production' ? user.email : APP_EMAIL, 61 | subject: 'Reset Password', 62 | template: "reset_password_link", 63 | 'h:X-Mailgun-Variables': JSON.stringify({ 64 | reset_password_link: resetPasswordLink 65 | }) 66 | }; 67 | 68 | 69 | try { 70 | const body = await mg.messages().send(data); 71 | console.log('body', body); 72 | 73 | } catch (e) { 74 | console.log('e', e); 75 | } 76 | } 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /server/lib/StripeManager.js: -------------------------------------------------------------------------------- 1 | const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); 2 | 3 | exports.createCustomer = async (paymentMethodId, email) => { 4 | const customer = await stripe.customers.create({ 5 | payment_method: paymentMethodId, 6 | email: email, 7 | invoice_settings: { 8 | default_payment_method: paymentMethodId, 9 | }, 10 | }); 11 | 12 | return customer; 13 | } 14 | 15 | exports.createSubscription = async (customerId, planId) => { 16 | const subscription = await stripe.subscriptions.create({ 17 | customer: customerId, 18 | items: [{ plan: planId }], 19 | expand: ["latest_invoice.payment_intent"] 20 | }); 21 | 22 | return subscription; 23 | } 24 | 25 | exports.updateSubscription = async (subscriptionId, newPlanId) => { 26 | const subscription = await stripe.subscriptions.retrieve(subscriptionId); 27 | const updatedSubscription = stripe.subscriptions.update(subscriptionId, { 28 | items: [{ 29 | id: subscription.items.data[0].id, 30 | plan: newPlanId 31 | }] 32 | }); 33 | 34 | return updatedSubscription; 35 | } 36 | 37 | exports.retrievePaymentMethod = async (paymentMethodId) => { 38 | const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); 39 | 40 | return paymentMethod; 41 | } 42 | 43 | exports.deleteSubscription = async (subscriptionId) => { 44 | const deletedSubscription = await stripe.subscriptions.del(subscriptionId) 45 | return deletedSubscription; 46 | } 47 | 48 | exports.attachPaymentMethodToCustomer = async (paymentMethodId, customerId) => { 49 | const paymentMethod = await stripe.paymentMethods.attach(paymentMethodId, { 50 | customer: customerId 51 | }); 52 | return paymentMethod; 53 | } 54 | 55 | exports.updateCustomer = async (customerId, customerData) => { 56 | const updatedCustomer = await stripe.customers.update(customerId, customerData); 57 | return updatedCustomer; 58 | } 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /server/lib/passport.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport'); 2 | const LocalStrategy = require('passport-local').Strategy; 3 | const JwtStrategy = require('passport-jwt').Strategy; 4 | const ExtractJwt = require('passport-jwt').ExtractJwt; 5 | const User = require('../models/User'); 6 | 7 | const localOptions = { 8 | usernameField: 'email', 9 | passwordField: 'password' 10 | }; 11 | const localLogin = new LocalStrategy(localOptions, function(email, password, done) { 12 | 13 | User.findOne({ email }, function (err, user) { 14 | if (err) { 15 | return done(err); 16 | } 17 | 18 | if (!user) { 19 | const errorObject = { 20 | error: true, 21 | errors: [{ 22 | code: 'GLOBAL_ERROR', 23 | message: 'Your login details could not be verified. Please try again.' 24 | }] 25 | }; 26 | 27 | // No user with that email address.. Cannot proceed 28 | done(null, null, errorObject); 29 | 30 | return; 31 | } 32 | 33 | 34 | // proceed with password validation 35 | user.comparePassword(password, function(err, isMatch) { 36 | if (err) { 37 | return done(err); 38 | } 39 | 40 | if (!isMatch) { 41 | const errorObject = { 42 | error: true, 43 | errors: [{ 44 | code: 'GLOBAL_ERROR', 45 | message: 'Your login details could not be verified. Please try again.' 46 | }] 47 | }; 48 | 49 | done(null, null, errorObject); 50 | 51 | return; 52 | } 53 | 54 | done(null, user); 55 | return; 56 | 57 | }) 58 | 59 | 60 | 61 | }); 62 | }); 63 | 64 | 65 | const jwtOpts = {} 66 | // jwtOpts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken(); 67 | 68 | jwtOpts.jwtFromRequest = (req) => { 69 | const cookies = req.cookies; 70 | const token = cookies.token; 71 | 72 | if (token) { 73 | return token; 74 | } 75 | 76 | const headers = req.headers || {}; 77 | const authHeader = headers.authorization || ''; 78 | const headerToken = authHeader.split(' ')[1]; 79 | if (headerToken) { 80 | return headerToken; 81 | } 82 | 83 | 84 | return null; 85 | } 86 | 87 | jwtOpts.secretOrKey = process.env.JWT_SECRET || 'TEMP_JWT_SECRET'; 88 | 89 | passport.use(new JwtStrategy(jwtOpts, function(jwtPayload, done) { 90 | const userId = jwtPayload._id; 91 | 92 | User.findOne({ _id: userId }, function(err, user) { 93 | if (err) { 94 | return done(err, false); 95 | } 96 | if (user) { 97 | return done(null, user); 98 | } else { 99 | return done(null, false, { 100 | code: 'GLOBAL_ERROR', 101 | message: 'Email not associated with any account' 102 | }); 103 | } 104 | }); 105 | })); 106 | 107 | 108 | passport.use(localLogin); 109 | 110 | 111 | -------------------------------------------------------------------------------- /server/models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | const bcrypt = require('bcrypt'); 4 | 5 | 6 | let User; 7 | 8 | if (!User) { 9 | let userSchema = new Schema({ 10 | email: { type: String, required: true, lowercase: true, unique: true }, 11 | password: { type: String, required: true }, 12 | 13 | // Fields related to account activation 14 | activated: { type: Boolean }, 15 | activationToken: { type: String, unique: true, sparse: true }, 16 | activationTokenSentAt: { type: Date }, 17 | activatedAt: { type: Date }, 18 | 19 | // Fields related to reset password 20 | resetPasswordToken: { type: String, unique: true, sparse: true }, 21 | resetPasswordTokenSentAt: { type: Date }, 22 | 23 | // Stripe Payments related 24 | stripeDetails: {} 25 | }, 26 | { 27 | timestamps: true 28 | }); 29 | 30 | 31 | userSchema.pre('save', function(next) { 32 | // this 33 | const user = this; 34 | 35 | const SALT_FACTOR = 5; 36 | 37 | if (!user.isModified('password')) { 38 | return next(); 39 | } 40 | 41 | bcrypt.genSalt(SALT_FACTOR, function(err, salt) { 42 | if (err) { 43 | return next(err); 44 | } 45 | 46 | bcrypt.hash(user.password, salt, function(err, hash) { 47 | if (err) { 48 | return next(err); 49 | } 50 | user.password = hash; 51 | 52 | next(); 53 | }) 54 | }) 55 | }); 56 | 57 | 58 | userSchema.methods.comparePassword = function(candidatePassword, callback) { 59 | bcrypt.compare(candidatePassword, this.password, function(err, isMatch) { 60 | if (err) { 61 | callback(err); 62 | return; 63 | } 64 | 65 | callback(null, isMatch); 66 | }) 67 | } 68 | 69 | userSchema.statics.toClientObject = function(user) { 70 | const userObject = user.toObject() || {}; 71 | 72 | const clientObject = { 73 | _id: userObject._id, 74 | email: userObject.email, 75 | activated: userObject.activated, 76 | createdAt: userObject.createdAt, 77 | updatedAt: userObject.updatedAt, 78 | stripeDetails: userObject.stripeDetails 79 | }; 80 | 81 | return clientObject; 82 | } 83 | 84 | userSchema.statics.saveStripeCustomer = function(id, customer) { 85 | const updatedUser = this.findOneAndUpdate( 86 | { _id: id }, 87 | { 'stripeDetails.customer': customer }, 88 | { new: true } 89 | ); 90 | 91 | return updatedUser; 92 | } 93 | 94 | userSchema.statics.saveStripeSubscription = function(id, subscription) { 95 | const updatedUser = this.findOneAndUpdate( 96 | { _id: id }, 97 | { 'stripeDetails.subscription': subscription }, 98 | { new: true } 99 | ); 100 | 101 | return updatedUser; 102 | } 103 | 104 | User = mongoose.model('User', userSchema); 105 | } 106 | 107 | module.exports = User; 108 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "bcrypt": "^3.0.7", 14 | "body-parser": "^1.19.0", 15 | "compression": "^1.7.4", 16 | "cookie-parser": "^1.4.4", 17 | "dotenv": "^8.2.0", 18 | "express": "^4.17.1", 19 | "jsonwebtoken": "^8.5.1", 20 | "lodash": "^4.17.15", 21 | "mailgun-js": "^0.22.0", 22 | "mongoose": "^5.7.12", 23 | "passport": "^0.4.0", 24 | "passport-jwt": "^4.0.0", 25 | "passport-local": "^1.0.0", 26 | "stripe": "^8.6.0", 27 | "uuid": "^3.3.3" 28 | }, 29 | "engines" : { 30 | "node" : ">=10.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/routes.js: -------------------------------------------------------------------------------- 1 | const authController = require('./controllers/authController'); 2 | const paymentsController = require('./controllers/paymentsController'); 3 | const passport = require('passport'); 4 | const checkAuth = passport.authenticate('jwt', { session: false }); 5 | const bodyParser = require('body-parser'); 6 | 7 | function addRoutes(app) { 8 | app.all('*', (req, res, next) => { 9 | console.log(req.method + ' ' + req.url); 10 | next(); 11 | }); 12 | 13 | app.get('/test-url', (req, res, next) => { 14 | res.send({ 15 | success: true 16 | }); 17 | }) 18 | 19 | app.get('/api/logged-in-user', checkAuth, authController.getLoggedInUser); 20 | 21 | 22 | app.post('/api/register', authController.register); 23 | app.post('/api/login', authController.login); 24 | app.post('/api/account-activate', authController.accountActivate); 25 | app.post('/api/resend-activation-link', authController.resendActivationLink); 26 | app.post('/api/reset-password-link', authController.resetPasswordLink); 27 | app.post('/api/reset-password', authController.resetPassword); 28 | 29 | app.get('/api/test-auth', checkAuth , authController.testAuth); 30 | 31 | // Payments 32 | app.post('/api/payments/subscriptions', checkAuth, paymentsController.createSubscription ); 33 | app.delete('/api/payments/subscriptions', checkAuth, paymentsController.deleteSubscription ); 34 | app.get('/api/payments/subscriptions', checkAuth, paymentsController.getSubscription ); 35 | app.get('/api/payments/cards', checkAuth, paymentsController.getCard ); 36 | app.post('/api/payments/cards', checkAuth, paymentsController.updateCard ); 37 | 38 | // Stripe Webhooks 39 | app.post('/api/stripe/webhooks', 40 | bodyParser.raw({ 41 | type: 'application/json' 42 | }), 43 | paymentsController.processStripeWebhook 44 | ); 45 | 46 | } 47 | 48 | const routes = { 49 | addRoutes 50 | }; 51 | 52 | module.exports = routes; 53 | 54 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const compression = require('compression'); 4 | const bodyParser = require('body-parser'); 5 | const cookieParser = require('cookie-parser'); 6 | const routes = require('./routes'); 7 | // connect to database on startup 8 | const databaseSetup = require('./db/databaseSetup'); 9 | const passport = require('./lib/passport'); 10 | const path = require('path'); 11 | const app = express(); 12 | 13 | app.use(compression()); 14 | app.use(bodyParser.json({ 15 | limit: '5mb', 16 | 17 | verify: (req, res, buf) => { 18 | if (req.originalUrl === '/api/stripe/webhooks') { 19 | req.rawBody = buf; 20 | } 21 | } 22 | })); 23 | app.use(cookieParser()); 24 | 25 | routes.addRoutes(app); 26 | 27 | if (process.env.NODE_ENV === 'production') { 28 | app.use(express.static(path.join(__dirname, '../client/build'))); 29 | app.get('*', (req, res, next) => { 30 | res.sendFile(path.resolve(__dirname, '../client/build/index.html')) 31 | }); 32 | } 33 | 34 | app.listen(process.env.PORT || 3001, () => { 35 | console.log('Express Server started on PORT: ', process.env.PORT || 3001); 36 | }); 37 | 38 | process.on('uncaughtException', (error) => { 39 | console.log('uncaughtException'); 40 | console.log('error ', error); 41 | }); 42 | -------------------------------------------------------------------------------- /xscreenshots/1.HomePage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/xscreenshots/1.HomePage.png -------------------------------------------------------------------------------- /xscreenshots/2.Register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/xscreenshots/2.Register.png -------------------------------------------------------------------------------- /xscreenshots/3.Login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/xscreenshots/3.Login.png -------------------------------------------------------------------------------- /xscreenshots/4.ResendActivationLink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/xscreenshots/4.ResendActivationLink.png -------------------------------------------------------------------------------- /xscreenshots/5.ResetPasswordLink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/xscreenshots/5.ResetPasswordLink.png -------------------------------------------------------------------------------- /xscreenshots/6.ResetPassword.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/xscreenshots/6.ResetPassword.png -------------------------------------------------------------------------------- /xscreenshots/7.Payments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/saasify/f8ce66aa36cf96008d274507a0975528d87341be/xscreenshots/7.Payments.png --------------------------------------------------------------------------------